Initial deploy setup
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-21 14:00:00 +02:00
commit b8091e59bd
285 changed files with 27854 additions and 0 deletions
View File
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
import pytest
import scripts.benchmark as benchmark
def test_load_benchmark_dataset():
dataset = benchmark.load_benchmark_dataset(Path("fixtures/benchmark/quality_benchmark.json"))
assert dataset["dataset_version"] == "vr116-2026-07-21"
assert isinstance(dataset["parser_coverage"], list)
assert isinstance(dataset["dedupe"], dict)
def test_percentile_helper_is_deterministic():
assert benchmark._percentile([1.0, 2.0, 3.0, 4.0], 50) == 2.0
assert benchmark._percentile([], 95) == 0.0
@pytest.mark.django_db
def test_run_benchmark_report_smoke(db):
report = benchmark.run_benchmark_report(
Path("fixtures/benchmark/quality_benchmark.json"),
quick=True,
top_n=20,
jobs=25,
skip_performance=True,
)
assert report["status"] in {"passed", "failed"}
assert report["jobs_processed"] == 25
assert report["top_n"] == 20
assert report["performance"]["status"] == "skipped"
assert report["nfr_007"]["measured"] is False
assert set(report["hardware"].keys()) >= {"platform", "python", "cpu_count"}
assert report["parser"]["coverage_ratio"] >= 0
assert report["ranking"]["top_n"] == 20
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from decimal import Decimal
import pytest
from django.contrib.auth import get_user_model
from apps.profiles.models import SearchProfile
from apps.sources.models import Source
@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):
return 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,
)
@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,251 @@
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 Application, Feedback, 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"
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")
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")
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")
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")
response = client.get(reverse("sources:list"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="sources")
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
assert expected_h1 in page.get_by_role("heading", level=1).all_text_contents()
page.keyboard.press("Tab")
focused_tag = page.evaluate("document.activeElement.tagName.toLowerCase()")
assert focused_tag != "body"
outline_style = page.evaluate("getComputedStyle(document.activeElement).outlineStyle")
assert outline_style != "none"
def _take_failure_screenshot(page, tmp_path: Path, viewport_name: str) -> None:
directory = tmp_path / "artifacts"
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": "desktop", "width": 1366, "height": 900},
{"name": "mobile-360", "width": 360, "height": 780},
],
)
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:
_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
_run_html_accessibility_probe(client, user, profile, job)
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()
try:
# Login.
page.goto(login_url)
_assert_playwright_accessibility(page, expected_h1="Welkom terug")
page.get_by_label("Gebruikersnaam").fill(user.username)
page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple")
page.get_by_role("button", name="Aanmelden").click()
page.wait_for_url(dashboard_url)
_assert_playwright_accessibility(page, expected_h1="Vandaag")
# Dashboard -> filter -> detail.
page.get_by_role("link", name="Vacatures").click()
page.get_by_role("textbox", name="Zoeken").fill(job.original_title)
page.get_by_role("button", name="Filter").click()
assert page.url.startswith(jobs_url)
page.get_by_role("link", name=job.original_title).first.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(".message.success")
page.get_by_role("button", name="Bewaren").click()
page.wait_for_selector(".message.success")
page.get_by_label("Reden (optioneel)").select_option("Te ver")
page.get_by_role("button", name="Verbergen").click()
page.wait_for_selector(".message.success")
page.get_by_role("button", name="Gesolliciteerd").click()
page.wait_for_selector(".message.success")
# Profiel aanpassen.
page.get_by_role("link", name="Profiel").click()
page.wait_for_url(profile_list_url)
page.get_by_role("link", name="Bewerken").click()
page.get_by_label("Gewenste functietitels").fill(
"Infrastructure Engineer\nSysteembeheerder"
)
page.get_by_role("button", name="Profiel opslaan").click()
page.wait_for_url(profile_list_url)
# Sollicitatie dossier openen en bijwerken.
application = Application.objects.filter(user=user, job=job).first()
assert application is not None
page.get_by_role("link", name="Sollicitaties").click()
page.wait_for_url(applications_url)
page.goto(f"{base}{reverse('jobs:application-edit', kwargs={'pk': application.pk})}")
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="Opslaan").click()
page.wait_for_url(applications_url)
# Handmatige import via plaktekst.
page.get_by_role("link", name="Brongezondheid").click()
page.wait_for_url(sources_url)
page.locator("textarea[name='pasted_text']").fill(
"Vacaturetitel: Infrastructure Engineer\n"
"Werkgever: Example IT\n"
"Locatie: 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="Brongezondheid")
assert Feedback.objects.filter(user=user, job=job).count() >= 4
except Exception:
_take_failure_screenshot(page, tmp_path, viewport["name"])
raise
finally:
context.close()
browser.close()
+191
View File
@@ -0,0 +1,191 @@
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_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,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,72 @@
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import pytest
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, RawDocument
from apps.sources.services.email_import import ingest_email, message_identity
@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_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
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from io import StringIO
from django.core.management import call_command
from django.core.management.base import CommandError
import pytest
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
+120
View File
@@ -0,0 +1,120 @@
import hashlib
from datetime import timedelta
from pathlib import Path
import pytest
from django.utils import timezone
from apps.jobs.models import Employer, JobPosting, JobSourceAlias, ScoreRun
from apps.jobs.services.pipeline import 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
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"
)
+132
View File
@@ -0,0 +1,132 @@
from datetime import datetime, time
from zoneinfo import ZoneInfo
from decimal import Decimal
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"
+170
View File
@@ -0,0 +1,170 @@
from datetime import timedelta
from django.utils import timezone
import pytest
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_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,111 @@
from __future__ import annotations
from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse
import pytest
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,217 @@
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from apps.jobs.models import 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, SourcePolicyReview, SourceLease, SourceRun
from apps.sources.services.fetcher import RateLimitedError, FetchedDocument
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_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()
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
assert client.get(reverse("jobs:applications")).status_code == 200
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):
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,
)
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
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
+51
View File
@@ -0,0 +1,51 @@
import pytest
from django.core.cache import cache
from django.test import override_settings
from django.urls import reverse
@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_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()
@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")
+94
View File
@@ -0,0 +1,94 @@
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
def test_platform_domains_are_denied_including_subdomains():
assert is_denied_domain("www.linkedin.com")
assert is_denied_domain("be.indeed.com")
assert not is_denied_domain("jobs.example.org")
assert not assess_url("https://www.linkedin.com/jobs/view/123").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):
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
+77
View File
@@ -0,0 +1,77 @@
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
@pytest.mark.security
def test_robots_uses_user_agent_matching_and_allows_default(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,
)
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):
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()
+18
View File
@@ -0,0 +1,18 @@
import pytest
from apps.jobs.services.sanitize import sanitize_job_html
@pytest.mark.security
def test_sanitize_removes_script_handlers_forms_and_iframes():
dirty = (
'<p onclick="alert(1)">Hallo</p><script>alert(1)</script>'
'<iframe src="x"></iframe><form><input></form>'
'<a href="javascript:alert(1)">x</a>'
)
clean = sanitize_job_html(dirty)
assert "script" not in clean.lower()
assert "onclick" not in clean.lower()
assert "iframe" not in clean.lower()
assert "form" not in clean.lower()
assert "javascript:" not in clean.lower()
+34
View File
@@ -0,0 +1,34 @@
import socket
import pytest
from apps.sources.services.url_security import UnsafeUrlError, validate_public_url
def fake_resolver(address: str):
def resolver(host, port, type=socket.SOCK_STREAM):
return [(socket.AF_INET, type, 6, "", (address, port))]
return resolver
@pytest.mark.security
def test_blocks_private_ipv4_after_dns_resolution():
with pytest.raises(UnsafeUrlError, match="Niet-publiek"):
validate_public_url("https://jobs.example.org/test", resolver=fake_resolver("10.0.0.5"))
@pytest.mark.security
def test_blocks_loopback_and_embedded_credentials():
with pytest.raises(UnsafeUrlError):
validate_public_url("http://127.0.0.1/admin")
with pytest.raises(UnsafeUrlError):
validate_public_url("https://user:pass@example.org/")
@pytest.mark.security
def test_accepts_global_address():
result = validate_public_url(
"https://jobs.example.org/test", resolver=fake_resolver("93.184.216.34")
)
assert result.hostname == "jobs.example.org"
+141
View File
@@ -0,0 +1,141 @@
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
def _evaluation_cases() -> list[dict[str, object]]:
path = Path(__file__).resolve().parents[1] / "fixtures" / "ai" / "evaluation_set.json"
return json.loads(path.read_text(encoding="utf-8"))["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
+123
View File
@@ -0,0 +1,123 @@
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_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_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_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"
+13
View File
@@ -0,0 +1,13 @@
from apps.sources.services.canonicalize import canonicalize_url, domain_matches
def test_canonicalize_url_removes_tracking_and_fragment():
value = canonicalize_url(
"HTTPS://Jobs.Example.org:443/jobs/../jobs/42/?utm_source=mail&b=2&a=1#apply"
)
assert value == "https://jobs.example.org/jobs/42/?a=1&b=2"
def test_domain_matches_subdomains_but_not_suffix_attack():
assert domain_matches("www.linkedin.com", "linkedin.com")
assert not domain_matches("linkedin.com.evil.example", "linkedin.com")
+107
View File
@@ -0,0 +1,107 @@
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
+12
View File
@@ -0,0 +1,12 @@
from pathlib import Path
from apps.sources.adapters.email_alert import EmailAlertAdapter
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)
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from apps.jobs.models import Employer, JobPosting
from apps.jobs.services.employer_resolution import resolve_direct_employer_match
from apps.jobs.services.normalization import CanonicalJobDraft
from apps.sources.models import Source
def _draft_for_test(job: JobPosting, *, **overrides) -> CanonicalJobDraft:
values = {
"source_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"canonical_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"external_id": "sr-security-123",
"title": job.original_title,
"normalized_title": job.normalized_title,
"job_family": job.job_family,
"employer_name": job.employer_name,
"employer_domain": "jobs.example.org",
"location_text": job.raw_location,
"region": job.region,
"municipality": job.municipality,
"postal_code": job.postal_code,
"country": job.country,
"workplace_type": job.workplace_type,
"employment_types": job.employment_types,
"language": "nl",
"description_html": "<p>Test</p>",
"description_text": "test beschrijving",
"date_posted": datetime(2026, 1, 10, tzinfo=UTC),
"valid_through": datetime(2026, 6, 1, tzinfo=UTC),
"compensation": {},
"skills_required": [],
"skills_preferred": [],
"content_hash": job.content_hash,
"canonical_key": "x" * 64,
"evidence": [],
"raw": {},
}
values.update(overrides)
return CanonicalJobDraft(**values)
@pytest.mark.django_db
def test_resolver_merges_recruiter_alias_to_direct_source():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="a" * 64,
job_family="security",
content_hash="a" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
# Use an existing direct employer job as the resolver target.
target_job = JobPosting.objects.first()
decision = resolve_direct_employer_match(
_draft_for_test(target_job),
source=source,
)
assert decision.job == target_job
assert decision.reason == "resolved_direct_match"
assert decision.canonical_url == "https://jobs.example.org/vacatures/security-engineer"
@pytest.mark.django_db
def test_resolver_flags_conflicting_location_for_review():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="b" * 64,
job_family="security",
content_hash="b" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
location_text="Gent",
municipality="Gent",
region="Oost-Vlaanderen",
),
source=source,
)
assert decision.job is None
assert decision.reason == "review_direct_conflict"
@pytest.mark.django_db
def test_resolver_keeps_candidate_unmerged_when_similarity_is_conservative():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="c" * 64,
job_family="security",
content_hash="c" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="StaffingBoard",
source_type=Source.Type.ATS,
base_url="https://jobs.staffingboard.com/example",
domain="jobs.staffingboard.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
title="Data Analyst",
normalized_title="data analyst",
location_text="Brussel",
employer_name="Another Employer",
employer_domain="jobs.staffingboard.com",
),
source=source,
)
assert decision.job is None
assert decision.reason == "no_direct_resolution"
+45
View File
@@ -0,0 +1,45 @@
from apps.jobs.services.distance import CommuteEstimate, estimate_commute, haversine_km
from apps.jobs.services.features import extract_deterministic_features, term_ratio
def test_feature_extraction_and_distance_are_deterministic():
text = (
"Voor een gemeente zoeken we een consultant. Rijbewijs B en 5 jaar ervaring vereist. "
"De rol bevat helpdesk en telefonische support bij klanten."
)
features = extract_deterministic_features("Support Engineer", text)
assert features["support_ratio"] > 0
assert features["consultancy_ratio"] > 0
assert features["travel_ratio"] > 0
assert features["public_sector_signal"] > 0
assert features["experience_years_max"] == 5
assert term_ratio("", ["x"]) == 0
assert 60 < haversine_km(50.9307, 5.3325, 50.8503, 4.3517) < 80
def test_conservative_commute_estimate_is_marked_as_estimate():
result = estimate_commute(20.0)
assert isinstance(result, CommuteEstimate)
assert result.is_estimate
assert result.minutes > 0
assert result.source == "road"
def test_custom_commute_estimator_is_accepted():
class CustomEstimator:
name = "custom"
version = "test"
def estimate(self, distance_km: float) -> CommuteEstimate:
return CommuteEstimate(
minutes=int(distance_km * 2),
km=distance_km,
source=self.name,
source_version=self.version,
confidence=0.7,
is_estimate=False,
)
result = estimate_commute(12.0, estimator=CustomEstimator())
assert result.minutes == 24
assert result.source_version == "test"
+33
View File
@@ -0,0 +1,33 @@
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"] == 26.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"] == 25.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"
+148
View File
@@ -0,0 +1,148 @@
import httpx
import pytest
from django.test import override_settings
from apps.sources.services.fetcher import (
ContentRejectedError,
FetchError,
FetchTimeoutError,
RateLimitedError,
PolicyBlockedError,
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)
def validate(url: str, **kwargs):
if "/final" in url:
return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=("198.51.100.12",))
return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=("93.184.216.34",))
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/start":
return httpx.Response(302, headers={"location": "/final"})
return httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"Vacature",
)
client = httpx.Client(transport=httpx.MockTransport(handler))
with pytest.raises(FetchError, match="DNS-rebindcontrole"):
fetch_url("https://example.org/start", client=client)
client.close()
def test_fetcher_rejects_policy_content_size_and_http(monkeypatch):
_patch_dns(monkeypatch)
with pytest.raises(PolicyBlockedError):
fetch_url("https://linkedin.com/jobs", client=httpx.Client())
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(200, headers={"content-type": "image/png"}, content=b"x")
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/image", client=client)
client.close()
with override_settings(FETCHER_MAX_BYTES=2):
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200, headers={"content-type": "text/plain"}, content=b"long"
)
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/large", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(500, content=b"error"))
)
with pytest.raises(FetchError, match="HTTP 500"):
fetch_url("https://example.org/error", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: (_ for _ in ()).throw(httpx.TimeoutException("timeout")))
)
with pytest.raises(FetchTimeoutError):
fetch_url("https://example.org/slow", client=client)
client.close()
def test_fetcher_rejects_redirect_without_location(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(302)))
with pytest.raises(FetchError, match="Location"):
fetch_url("https://example.org/start", client=client)
client.close()
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from apps.sources.adapters.generic_html import GenericHtmlAdapter
def test_generic_html_fallback_extracts_core_fields():
html = Path("fixtures/pages/sample_generic_job.html").read_text(encoding="utf-8")
result = GenericHtmlAdapter().extract(html, url="https://careers.example.net/jobs/1")
assert result.jobs[0].title == "Workplace Engineer"
assert result.jobs[0].employer_name == "Example Health"
assert "Microsoft Intune" in result.jobs[0].description_text
+210
View File
@@ -0,0 +1,210 @@
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\n"
"9000,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
+15
View File
@@ -0,0 +1,15 @@
import json
from pathlib import Path
from apps.sources.adapters.jsonld import JsonLdJobPostingAdapter
def test_extracts_sample_jobposting():
html = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
expected = json.loads(Path("fixtures/golden/sample_jsonld_job.json").read_text())
result = JsonLdJobPostingAdapter().extract(html, url="https://jobs.example.org/source")
assert result.confidence >= 0.9
assert len(result.jobs) == 1
job = result.jobs[0]
for key, value in expected.items():
assert getattr(job, key) == value
+87
View File
@@ -0,0 +1,87 @@
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)
+15
View File
@@ -0,0 +1,15 @@
from apps.jobs.services.normalization import (
infer_language,
normalize_employment_types,
normalize_title,
)
def test_title_and_employment_normalization():
assert normalize_title("Senior System Engineer (m/v/x) - Fulltime") == "senior system engineer"
assert normalize_employment_types(["FULL_TIME", "vast"]) == ["full_time", "permanent"]
def test_language_detection_is_conservative():
assert infer_language("Je beheert de infrastructuur en werkt met een team") == "nl"
assert infer_language("You manage the infrastructure and work with a team") == "en"
+71
View File
@@ -0,0 +1,71 @@
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)
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()
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from django.core.cache import cache
from django.test import RequestFactory
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
@pytest.fixture(autouse=True)
def _clear_rate_limit_cache():
cache.clear()
yield
cache.clear()
def _post_request(payload: dict[str, str], *, username: str | None = None):
factory = RequestFactory()
request = factory.post("/login/", data=payload)
request.META["REMOTE_ADDR"] = "203.0.113.10"
if username is None:
request.user = SimpleNamespace(is_authenticated=False, is_anonymous=True)
else:
request.user = SimpleNamespace(is_authenticated=True, pk=42)
payload["username"] = username
request.user.username = username
return request
def test_rate_limit_blocks_after_max_attempts_and_reports_block_state():
request = _post_request({"username": "alice"}, username="alice")
state = is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 0
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 1
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert state.is_blocked
assert state.remaining_seconds > 0
assert is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
).is_blocked
def test_rate_limit_clear_resets_state_for_authenticated_identity():
request = _post_request({"username": "alice"}, username="alice")
state = register_rate_limit_failure(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert state.is_blocked
clear_rate_limit(request, namespace="login")
state = is_rate_limited(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert not state.is_blocked
assert state.attempts == 0
+83
View File
@@ -0,0 +1,83 @@
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=timezone.now())
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.EMPLOYER,
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
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from decimal import Decimal
import pytest
from apps.jobs.models import AiAnalysisCache
from apps.jobs.services.ai import AiAnalysis
from apps.jobs.models import Employer, JobPosting, ScoreRun
from apps.jobs.services import scoring
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_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_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 = None
matching_job.municipality = None
matching_job.raw_location = None
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 = None
matching_job.municipality = None
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__
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import pytest
from django.core.exceptions import ImproperlyConfigured
from config.settings import _validate_production_security
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, ["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,
)
+42
View File
@@ -0,0 +1,42 @@
from datetime import timedelta
import pytest
from django.core.exceptions import ValidationError
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