Publish curated VacatureRadar source snapshot

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

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
import json
from pathlib import Path
import httpx
import pytest
from django.test import override_settings
from apps.jobs.models import AiAnalysisCache
from apps.jobs.services import ai as ai_service
from apps.jobs.services.ai import analyze_job_text
pytestmark = pytest.mark.django_db
def _evaluation_cases() -> list[dict[str, object]]:
path = Path(__file__).resolve().parents[2] / "fixtures" / "ai" / "evaluation_set.json"
return json.loads(path.read_text(encoding="utf-8-sig"))["cases"]
@pytest.mark.parametrize("case", _evaluation_cases(), ids=lambda case: case["id"])
def test_ai_evaluation_cases(monkeypatch, case):
if case["response_type"] == "disabled":
with override_settings(OLLAMA_ENABLED=False, OLLAMA_MODEL=""):
result = analyze_job_text(
title=case["title"],
description=case["description"],
content_hash=case["content_hash"],
)
assert result.status == ai_service.AiAnalysisCache.Status.DISABLED
assert result.error_category == "ollama_disabled"
assert result.cached is False
assert result.summary_nl == ""
return
if case["response_type"] == "timeout":
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
raise httpx.TimeoutException("timeout")
monkeypatch.setattr(httpx, "Client", FakeClient)
else:
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
return _fake_response(case["response"])
monkeypatch.setattr(httpx, "Client", FakeClient)
with override_settings(
OLLAMA_ENABLED=True,
OLLAMA_MODEL="local-model",
OLLAMA_BASE_URL="http://ollama:11434",
OLLAMA_TIMEOUT_SECONDS=2,
):
result = analyze_job_text(
title=case["title"],
description=case["description"],
content_hash=case["content_hash"],
)
assert result.status == case["expected_status"]
assert result.error_category == case["expected_error_category"]
assert result.schema_version == "1.0.0"
if result.status == ai_service.AiAnalysisCache.Status.OK:
assert result.summary_nl == case["expected_summary"]
assert round(result.features.get("support_ratio", 0), 3) == case["expected_support_ratio"]
assert result.features.get("evidence") == case["expected_evidence"]
if result.status != ai_service.AiAnalysisCache.Status.OK:
assert result.features == {}
def _fake_response(response_text: object):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"response": response_text}
return FakeResponse()
def test_ai_cache_reuses_result(monkeypatch):
calls = 0
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
nonlocal calls
calls += 1
return _fake_response(
'{"summary_nl":"Gevalideerde IT-ops advertentie.",'
'"features":{"support_ratio":0.03,"consultancy_ratio":0.01,'
'"travel_ratio":0.02,"seniority":"senior","evidence":["IT"]},'
'"warnings":[]}'
)
monkeypatch.setattr(httpx, "Client", FakeClient)
with override_settings(
OLLAMA_ENABLED=True,
OLLAMA_MODEL="local-model",
OLLAMA_BASE_URL="http://ollama:11434",
):
first = analyze_job_text(
"Cloud Engineer",
"Cloud Engineer vacature. IT-ops taken incl. Azure en Linux.",
content_hash="cache-hit-case",
)
second = analyze_job_text(
"Cloud Engineer",
"Cloud Engineer vacature. IT-ops taken incl. Azure en Linux.",
content_hash="cache-hit-case",
)
assert first.status == ai_service.AiAnalysisCache.Status.OK
assert second.status == ai_service.AiAnalysisCache.Status.OK
assert first.cached is False
assert second.cached is True
assert calls == 1
assert AiAnalysisCache.objects.count() == 1
+215
View File
@@ -0,0 +1,215 @@
from pathlib import Path
from apps.sources.adapters.ats import (
GreenhouseAdapter,
LeverAdapter,
RecruiteeAdapter,
SmartRecruitersAdapter,
WorkableAdapter,
)
def _load_fixture(name: str) -> str:
return Path(name).read_text(encoding="utf-8")
def _assert_evidence_fields(job, *, expected_keys):
evidence_fields = {item.field_name for item in job.evidence}
assert expected_keys.issubset(evidence_fields)
def test_greenhouse_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = GreenhouseAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/greenhouse-listing.json"),
url="https://boards.greenhouse.io/example",
)
assert result.parser_key == "ats-greenhouse"
assert len(result.jobs) == 2
assert {job.external_id for job in result.jobs} == {"gh-open-1", "gh-open-2"}
assert all("closed" not in result.warnings for _ in [0])
assert result.confidence >= 0.89
_assert_evidence_fields(
result.jobs[0],
expected_keys={"external_id", "url", "location_text", "date_posted", "employment_types"},
)
def test_greenhouse_adapter_detail_supports_changed_markup():
adapter = GreenhouseAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/greenhouse-detail.json"),
url="https://boards.greenhouse.io/example/jobs/senior-software-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Senior Software Engineer"
assert job.external_id == "gh-open-1"
assert job.url == "https://boards.greenhouse.io/example/jobs/senior-software-engineer"
assert job.description_html
_assert_evidence_fields(
job,
expected_keys={
"external_id",
"url",
"location_text",
"date_posted",
"valid_through",
"employment_types",
},
)
def test_lever_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = LeverAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/lever-listing.json"), url="https://jobs.lever.co/example"
)
assert result.parser_key == "ats-lever"
assert len(result.jobs) == 2
assert {job.external_id for job in result.jobs} == {"lv-open-1", "lv-open-2"}
assert result.confidence >= 0.89
def test_lever_adapter_detail_supports_changed_markup():
adapter = LeverAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/lever-detail.json"),
url="https://jobs.lever.co/example/platform-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Platform Engineer"
assert job.raw["position"]["workplaceType"] == "remote"
assert "work_type" in job.raw
assert job.location_text == "Aalst"
def test_lever_adapter_supports_documented_public_postings_api_shape():
adapter = LeverAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/lever-public-postings.json"),
url="https://api.lever.co/v0/postings/deliverect?mode=json",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Backend Engineer"
assert job.url == "https://jobs.lever.co/deliverect/deliverect-open-1"
assert job.location_text == "Ghent, Flemish Region"
assert job.employment_types == ["Full-time Permanent"]
assert job.workplace_type == "hybrid"
def test_recruitee_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = RecruiteeAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/recruitee-listing.json"), url="https://acme.recruitee.com/o/"
)
assert result.parser_key == "ats-recruitee"
assert len(result.jobs) == 2
assert {job.title for job in result.jobs} == {"System Engineer", "Network Engineer"}
def test_recruitee_adapter_detail_supports_changed_markup():
adapter = RecruiteeAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/recruitee-detail.json"),
url="https://acme.recruitee.com/o/system-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.external_id == "rc-open-1"
assert job.title == "System Engineer"
assert job.employment_types == ["permanent"]
def test_recruitee_adapter_supports_documented_public_offers_shape():
adapter = RecruiteeAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/recruitee-public-offers.json"),
url="https://vito.recruitee.com/api/offers/",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Research Software Engineer"
assert job.employer_name == "VITO"
assert job.url == "https://jobs.vito.be/o/research-software-engineer"
assert job.postal_code == "2400"
assert job.employment_types == ["fulltime_fixed_term"]
assert job.workplace_type == "hybrid"
def test_smartrecruiters_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = SmartRecruitersAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/smartrecruiters-listing.json"),
url="https://jobs.smartrecruiters.com/example",
)
assert result.parser_key == "ats-smartrecruiters"
assert len(result.jobs) == 2
assert "sr-closed-1" not in {job.external_id for job in result.jobs}
def test_smartrecruiters_adapter_detail_supports_changed_markup():
adapter = SmartRecruitersAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/smartrecruiters-detail.json"),
url="https://jobs.smartrecruiters.com/example/security-engineer",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Security Engineer"
assert job.workplace_type in {"remote", "hybrid", ""}
def test_smartrecruiters_adapter_supports_documented_public_postings_shape():
adapter = SmartRecruitersAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/smartrecruiters-public-postings.json"),
url=("https://api.smartrecruiters.com/v1/companies/ARHS/postings?limit=100&country=be"),
)
assert len(result.jobs) == 2
job = result.jobs[1]
assert job.title == "Cloud Engineer"
assert job.employer_name == "ARHS"
assert job.url == "https://jobs.smartrecruiters.com/ARHS/744000136173889"
assert job.location_text == "Leuven"
assert job.country == "be"
assert job.employment_types == ["Permanent"]
assert job.workplace_type == "remote"
def test_workable_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = WorkableAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/workable-listing.json"),
url="https://apply.workable.com/example",
)
assert result.parser_key == "ats-workable"
assert len(result.jobs) == 2
assert result.jobs[0].title == "Data Engineer"
def test_workable_adapter_detail_supports_changed_markup():
adapter = WorkableAdapter()
result = adapter.extract(
_load_fixture("fixtures/ats/workable-detail.json"),
url="https://apply.workable.com/example/data-engineer/",
)
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.external_id == "wb-open-1"
assert job.location_text == "Brussel"
+87
View File
@@ -0,0 +1,87 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from apps.core.auth_backends import EmailOrUsernameModelBackend
from apps.core.entra import (
EntraConfigError,
domain_allowed,
entra_enabled,
extract_identity,
load_config,
)
def test_auth_backend_rejects_missing_and_unknown_credentials(db):
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username=None, password=None) is None
assert backend.authenticate(None, username="unknown@example.invalid", password="secret") is None
def test_auth_backend_rejects_inactive_and_wrong_password(db):
user = get_user_model().objects.create_user(
username="inactive", email="inactive@example.invalid", password="correct", is_active=False
)
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username=user.email, password="wrong") is None
assert backend.authenticate(None, username=user.email, password="correct") is None
def test_auth_backend_handles_duplicate_email_without_ambiguous_login(db):
user_model = get_user_model()
first = user_model.objects.create_user(
username="first", email="shared@example.invalid", password="correct"
)
user_model.objects.create_user(
username="second", email="shared@example.invalid", password="correct"
)
backend = EmailOrUsernameModelBackend()
assert backend.authenticate(None, username="shared@example.invalid", password="correct") is None
assert backend.authenticate(None, username="first", password="correct") == first
@override_settings(ENTRA_ID_ENABLED=True)
def test_entra_enabled_reflects_configuration():
assert entra_enabled() is True
@override_settings(
ENTRA_ID_ENABLED=True, ENTRA_CLIENT_ID="", ENTRA_CLIENT_SECRET="", ENTRA_TENANT_ID=""
)
def test_entra_config_fails_closed_when_credentials_are_incomplete():
try:
load_config()
except EntraConfigError as exc:
assert "niet allemaal ingesteld" in str(exc)
else: # pragma: no cover - defensive assertion
raise AssertionError("Onvolledige Entra-configuratie werd geaccepteerd")
@override_settings(
ENTRA_CLIENT_ID="client",
ENTRA_CLIENT_SECRET="secret",
ENTRA_TENANT_ID="tenant",
ENTRA_SCOPES=["User.Read"],
ENTRA_ALLOWED_DOMAINS=["@Example.COM"],
ENTRA_REDIRECT_URI="https://example.invalid/callback",
)
def test_entra_config_normalizes_domains_and_identity_claims():
config = load_config()
assert config.authority.endswith("/tenant")
assert config.allowed_domains == ("example.com",)
assert domain_allowed(config, "person@example.com") is True
assert domain_allowed(config, "person@other.invalid") is False
assert extract_identity(
{"id_token_claims": {"preferred_username": " person@example.com ", "name": " Person "}}
) == ("person@example.com", "Person")
@override_settings(
ENTRA_CLIENT_ID="client",
ENTRA_CLIENT_SECRET="secret",
ENTRA_TENANT_ID="tenant",
ENTRA_ALLOWED_DOMAINS=[],
)
def test_entra_allows_any_domain_only_when_allowlist_is_empty():
assert domain_allowed(load_config(), "person@example.invalid") is True
+13
View File
@@ -0,0 +1,13 @@
from apps.sources.services.canonicalize import canonicalize_url, domain_matches
def test_canonicalize_url_removes_tracking_and_fragment():
value = canonicalize_url(
"HTTPS://Jobs.Example.org:443/jobs/../jobs/42/?utm_source=mail&b=2&a=1#apply"
)
assert value == "https://jobs.example.org/jobs/42/?a=1&b=2"
def test_domain_matches_subdomains_but_not_suffix_attack():
assert domain_matches("www.linkedin.com", "linkedin.com")
assert not domain_matches("linkedin.com.evil.example", "linkedin.com")
+112
View File
@@ -0,0 +1,112 @@
from pathlib import Path
import pytest
from apps.sources.models import Source
from apps.sources.services.discovery import (
SourceCandidate,
discover_career_links,
discover_from_email,
discover_from_feed,
discover_from_html,
discover_from_sitemap,
persist_discovery_candidates,
)
def test_discovery_finds_career_links_and_skips_denied_domains():
html = """
<a href="/werken-bij">Werken bij ons</a>
<a href="https://careers.partner.example/jobs">Jobs partner</a>
<a href="https://www.linkedin.com/jobs">Jobs op LinkedIn</a>
<a href="/about">Over ons</a>
"""
result = discover_career_links(html, base_url="https://example.org/")
assert [item.url for item in result] == [
"https://example.org/werken-bij",
"https://careers.partner.example/jobs",
]
assert result[0].confidence > result[1].confidence
def test_discovery_from_html_includes_jsonld_and_feed_links():
html = Path("fixtures/discovery/html_candidate_discovery.html").read_text(encoding="utf-8")
result = discover_from_html(html, base_url="https://example.org/")
candidate_urls = {candidate.url: candidate.discovered_from for candidate in result}
assert candidate_urls["https://example.org/werken-bij"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/data-engineer"] == "html-jsonld"
assert (
candidate_urls["https://jobs.example.org/vacatures/data-engineer/solliciteer"]
== "html-jsonld"
)
assert candidate_urls["https://example.org/feed/jobs.xml"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/data-engineer"].startswith(
"html-jsonld"
)
assert any(c.source_type == Source.Type.RSS and c.reason == "feed" for c in result)
assert any(
c.source_type == Source.Type.EMPLOYER and c.url == "https://example.org/werken-bij"
for c in result
)
def test_discovery_from_feed_detects_internal_links_only():
content = Path("fixtures/discovery/feed_jobs.xml").read_text(encoding="utf-8")
result = discover_from_feed(content, base_url="https://jobs.example.org/feed/jobs")
assert len(result) == 1
assert result[0].url == "https://jobs.example.org/vacatures/cloud-architect"
assert result[0].source_type == Source.Type.RSS
def test_discovery_from_sitemap_prefers_candidates_and_filters_private_or_denied():
index_content = Path("fixtures/discovery/sitemap_index.xml").read_text(encoding="utf-8")
urlset_content = Path("fixtures/discovery/sitemap_urlset.xml").read_text(encoding="utf-8")
index_candidates = discover_from_sitemap(index_content, base_url="https://jobs.example.org/")
urlset_candidates = discover_from_sitemap(urlset_content, base_url="https://jobs.example.org/")
assert len(index_candidates) == 1
assert index_candidates[0].url == "https://jobs.example.org/sitemap-jobs.xml"
assert len(urlset_candidates) >= 2
assert any(
item.url == "https://jobs.example.org/vacatures/data-engineer" for item in urlset_candidates
)
assert all("linkedin.com" not in item.url for item in index_candidates + urlset_candidates)
def test_discovery_from_email_extracts_employer_domain_root():
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
result = discover_from_email(raw)
assert [item.url for item in result] == ["https://jobs.example.org/"]
assert result[0].source_type == Source.Type.EMPLOYER
@pytest.mark.django_db
def test_discovery_persistence_is_idempotent():
candidates = [
SourceCandidate(
url="https://jobs.example.org/vacatures/data-engineer",
domain="jobs.example.org",
source_type=Source.Type.EMPLOYER,
label="Testbron",
confidence=0.95,
reason="test",
discovered_from="unit-test",
)
]
created, updated, skipped = persist_discovery_candidates(candidates)
assert (created, updated, skipped) == (1, 0, 0)
created, updated, skipped = persist_discovery_candidates(candidates)
assert (created, updated, skipped) == (0, 0, 1)
source = Source.objects.get(domain="jobs.example.org", source_type=Source.Type.EMPLOYER)
assert source.status == Source.Status.CANDIDATE
assert source.policy == Source.Policy.REVIEW
assert source.name == "Testbron"
assert len(source.discovery_evidence) == 1
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path
import pytest
from apps.sources.adapters.email_alert import EmailAlertAdapter
from apps.sources.models import MailboxConnection
from apps.sources.platforms import PLATFORM_ALERTS
def test_email_adapter_extracts_job_links_and_skips_unsubscribe():
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
result = EmailAlertAdapter().extract_message(raw)
urls = {job.url for job in result.jobs}
assert "https://jobs.example.org/vacatures/infrastructure-engineer" in urls
assert "https://www.linkedin.com/jobs/view/123456" in urls
assert all("unsubscribe" not in url for url in urls)
def test_email_adapter_recognizes_sanitized_vdab_alert():
raw = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
result = EmailAlertAdapter().extract_message(raw)
assert {job.title for job in result.jobs} == {"Python developer", "Cloud engineer"}
assert all(job.raw["alert_provider"] == "vdab" for job in result.jobs)
assert all(job.raw["target_domain"] == "www.vdab.be" for job in result.jobs)
assert all("uitschrijven" not in job.url for job in result.jobs)
@pytest.mark.parametrize(
("provider", "fixture", "title", "domain"),
(
("linkedin", "linkedin_job_alert.eml", "Software Engineer bij Example", "linkedin.com"),
("ictjob", "ictjob_job_alert.eml", "Cloud Engineer", "ictjob.be"),
("jobat", "jobat_job_alert.eml", "Data Engineer", "jobat.be"),
("stepstone", "stepstone_job_alert.eml", "Software Developer", "stepstone.be"),
("careerjet", "careerjet_job_alert.eml", "System Engineer", "careerjet.be"),
("randstad", "randstad_job_alert.eml", "Infrastructure Engineer", "randstad.be"),
("roberthalf", "roberthalf_job_alert.eml", "Network Engineer", "roberthalf.com"),
),
)
def test_platform_bound_alert_extracts_only_expected_https_jobboard_link(
provider, fixture, title, domain
):
raw = (Path("fixtures/emails") / fixture).read_bytes()
result = EmailAlertAdapter().extract_message(raw, expected_provider=provider)
assert [job.title for job in result.jobs] == [title]
assert result.jobs[0].raw["alert_provider"] == provider
assert result.jobs[0].raw["target_domain"].endswith(domain)
assert result.jobs[0].url.startswith("https://")
assert "outside.example.invalid" not in result.jobs[0].url
def test_mailbox_platform_choices_and_alert_catalog_stay_in_sync():
assert set(MailboxConnection.Platform.values) == set(PLATFORM_ALERTS)
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from apps.jobs.models import Employer, JobPosting
from apps.jobs.services.employer_resolution import resolve_direct_employer_match
from apps.jobs.services.normalization import CanonicalJobDraft
from apps.sources.models import Source
def _draft_for_test(job: JobPosting, **overrides) -> CanonicalJobDraft:
values = {
"source_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"canonical_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"external_id": "sr-security-123",
"title": job.original_title,
"normalized_title": job.normalized_title,
"job_family": job.job_family,
"employer_name": job.employer_name,
"employer_domain": "jobs.example.org",
"location_text": job.raw_location,
"region": job.region,
"municipality": job.municipality,
"postal_code": job.postal_code,
"country": job.country,
"workplace_type": job.workplace_type,
"employment_types": job.employment_types,
"language": "nl",
"description_html": "<p>Test</p>",
"description_text": "test beschrijving",
"date_posted": datetime(2026, 1, 10, tzinfo=UTC),
"valid_through": datetime(2026, 6, 1, tzinfo=UTC),
"compensation": {},
"skills_required": [],
"skills_preferred": [],
"content_hash": job.content_hash,
"canonical_key": "x" * 64,
"evidence": [],
"raw": {},
}
values.update(overrides)
return CanonicalJobDraft(**values)
@pytest.mark.django_db
def test_resolver_merges_recruiter_alias_to_direct_source():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="a" * 64,
job_family="security",
content_hash="a" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
# Use an existing direct employer job as the resolver target.
target_job = JobPosting.objects.first()
decision = resolve_direct_employer_match(
_draft_for_test(target_job),
source=source,
)
assert decision.job == target_job
assert decision.reason == "resolved_direct_match"
assert decision.canonical_url == "https://jobs.example.org/vacatures/security-engineer"
@pytest.mark.django_db
def test_resolver_flags_conflicting_location_for_review():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="b" * 64,
job_family="security",
content_hash="b" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
location_text="Gent",
municipality="Gent",
region="Oost-Vlaanderen",
),
source=source,
)
assert decision.job is None
assert decision.reason == "review_direct_conflict"
@pytest.mark.django_db
def test_resolver_keeps_candidate_unmerged_when_similarity_is_conservative():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="c" * 64,
job_family="security",
content_hash="c" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="StaffingBoard",
source_type=Source.Type.ATS,
base_url="https://jobs.staffingboard.com/example",
domain="jobs.staffingboard.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
title="Data Analyst",
normalized_title="data analyst",
location_text="Brussel",
employer_name="Another Employer",
employer_domain="jobs.staffingboard.com",
),
source=source,
)
assert decision.job is None
assert decision.reason == "no_direct_resolution"
+45
View File
@@ -0,0 +1,45 @@
from apps.jobs.services.distance import CommuteEstimate, estimate_commute, haversine_km
from apps.jobs.services.features import extract_deterministic_features, term_ratio
def test_feature_extraction_and_distance_are_deterministic():
text = (
"Voor een gemeente zoeken we een consultant. Rijbewijs B en 5 jaar ervaring vereist. "
"De rol bevat helpdesk en telefonische support bij klanten."
)
features = extract_deterministic_features("Support Engineer", text)
assert features["support_ratio"] > 0
assert features["consultancy_ratio"] > 0
assert features["travel_ratio"] > 0
assert features["public_sector_signal"] > 0
assert features["experience_years_max"] == 5
assert term_ratio("", ["x"]) == 0
assert 60 < haversine_km(50.9307, 5.3325, 50.8503, 4.3517) < 80
def test_conservative_commute_estimate_is_marked_as_estimate():
result = estimate_commute(20.0)
assert isinstance(result, CommuteEstimate)
assert result.is_estimate
assert result.minutes > 0
assert result.source == "road"
def test_custom_commute_estimator_is_accepted():
class CustomEstimator:
name = "custom"
version = "test"
def estimate(self, distance_km: float) -> CommuteEstimate:
return CommuteEstimate(
minutes=int(distance_km * 2),
km=distance_km,
source=self.name,
source_version=self.version,
confidence=0.7,
is_estimate=False,
)
result = estimate_commute(12.0, estimator=CustomEstimator())
assert result.minutes == 24
assert result.source_version == "test"
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from apps.jobs.models import Feedback
from apps.jobs.services.feedback import record_feedback
def test_learning_signals_wait_for_min_samples(profile, job, user):
first = record_feedback(user=user, job=job, action=Feedback.Action.INTERESTING)
second = record_feedback(user=user, job=job, action=Feedback.Action.INTERESTING)
first_metadata = first.metadata.get("learning", {})
second_metadata = second.metadata.get("learning", {})
assert first_metadata.get("status") == "queued"
assert first_metadata.get("reason_code") == "positive_feedback"
assert second_metadata.get("status") == "applied"
profile.refresh_from_db()
assert profile.weights["content"] == 31.0
def test_learning_skip_for_implicit_hide(profile, job, user):
feedback = record_feedback(user=user, job=job, action=Feedback.Action.HIDE)
metadata = feedback.metadata.get("learning", {})
assert metadata["status"] == "queued"
assert metadata["reason_code"] == "implicit_hide_no_reason"
profile.refresh_from_db()
assert profile.weights["content"] == 30.0
def test_learning_marks_explicit_title_hide_as_nonlearning(profile, job, user):
feedback = record_feedback(
user=user, job=job, action=Feedback.Action.HIDE, reason="Titel past niet bij mij"
)
metadata = feedback.metadata.get("learning", {})
assert metadata["status"] == "queued"
assert metadata["reason_code"] == "non_learning_title"
+226
View File
@@ -0,0 +1,226 @@
import httpx
import pytest
from django.test import override_settings
from apps.sources.services.fetcher import (
ContentRejectedError,
FetchError,
FetchTimeoutError,
PolicyBlockedError,
RateLimitedError,
_validate_connected_peer,
fetch_url,
)
from apps.sources.services.url_security import ValidatedUrl
PUBLIC_DNS = [(None, None, None, None, ("93.184.216.34", 443))]
def _patch_dns(monkeypatch):
monkeypatch.setattr(
"apps.sources.services.url_security.socket.getaddrinfo",
lambda *args, **kwargs: PUBLIC_DNS,
)
# validate_public_url captured the default resolver at definition time, so patch call site too.
original = __import__(
"apps.sources.services.url_security", fromlist=["validate_public_url"]
).validate_public_url
def validate(url, **kwargs):
return original(url, resolver=lambda *a, **k: PUBLIC_DNS, **kwargs)
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def test_fetcher_success_redirect_304_and_hash(monkeypatch):
_patch_dns(monkeypatch)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/start":
return httpx.Response(302, headers={"location": "/job"})
return httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"Vacature",
)
client = httpx.Client(transport=httpx.MockTransport(handler))
document = fetch_url("https://example.org/start", client=client)
assert document.final_url == "https://example.org/job"
assert document.text == "Vacature"
assert len(document.sha256) == 64
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(304, headers={"etag": "x"}))
)
unchanged = fetch_url("https://example.org/job", client=client)
assert unchanged.status_code == 304
assert unchanged.content == b""
client.close()
def test_fetcher_respects_rate_limit_and_retry_after(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
429, headers={"retry-after": "45"}, content=b"too many requests"
)
)
)
with pytest.raises(RateLimitedError) as exc:
fetch_url("https://example.org/too-fast", client=client)
assert exc.value.retry_after_seconds == 45
client.close()
def test_fetcher_rejects_dns_rebinding(monkeypatch):
_patch_dns(monkeypatch)
calls = 0
def validate(url: str, **kwargs):
nonlocal calls
calls += 1
address = "93.184.216.34" if calls == 1 else "198.51.100.12"
return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=(address,))
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"Vacature",
)
)
)
with pytest.raises(FetchError, match="DNS-rebindcontrole"):
fetch_url("https://example.org/start", client=client)
client.close()
def test_production_transport_fails_closed_without_connected_peer():
validated = ValidatedUrl(
url="https://example.org/jobs",
hostname="example.org",
port=443,
addresses=("93.184.216.34",),
)
response = httpx.Response(200)
with pytest.raises(FetchError, match="Verbonden IP-adres"):
_validate_connected_peer(
response,
before=validated,
after=validated,
require_peer=True,
)
class _PeerStream:
def __init__(self, address: str) -> None:
self.address = address
def get_extra_info(self, key: str):
return (self.address, 443) if key == "server_addr" else None
def test_fetcher_accepts_public_cdn_rotation_when_connected_peer_is_public(monkeypatch):
calls = 0
def validate(url: str, **kwargs):
nonlocal calls
calls += 1
address = "150.171.109.35" if calls == 1 else "150.171.109.36"
return ValidatedUrl(url=url, hostname="cdn.example.org", port=443, addresses=(address,))
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def handler(request: httpx.Request) -> httpx.Response:
status_code = 302 if request.url.path == "/start" else 200
headers = {"location": "/jobs"} if status_code == 302 else {"content-type": "text/html"}
return httpx.Response(
status_code,
headers=headers,
content=b"" if status_code == 302 else b"Vacature",
extensions={"network_stream": _PeerStream("150.171.109.36")},
)
client = httpx.Client(transport=httpx.MockTransport(handler))
document = fetch_url("https://cdn.example.org/start", client=client)
assert document.status_code == 200
assert document.final_url == "https://cdn.example.org/jobs"
client.close()
def test_fetcher_rejects_private_connected_peer(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200,
headers={"content-type": "text/html"},
content=b"Vacature",
extensions={"network_stream": _PeerStream("10.0.0.5")},
)
)
)
with pytest.raises(FetchError, match="Niet-publiek verbonden IP-adres"):
fetch_url("https://example.org/jobs", client=client)
client.close()
def test_fetcher_rejects_policy_content_size_and_http(monkeypatch):
_patch_dns(monkeypatch)
with pytest.raises(PolicyBlockedError):
fetch_url("https://linkedin.com/jobs", client=httpx.Client())
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(200, headers={"content-type": "image/png"}, content=b"x")
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/image", client=client)
client.close()
with override_settings(FETCHER_MAX_BYTES=2):
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200, headers={"content-type": "text/plain"}, content=b"long"
)
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/large", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(500, content=b"error"))
)
with pytest.raises(FetchError, match="HTTP 500"):
fetch_url("https://example.org/error", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: (_ for _ in ()).throw(httpx.TimeoutException("timeout"))
)
)
with pytest.raises(FetchTimeoutError):
fetch_url("https://example.org/slow", client=client)
client.close()
def test_fetcher_rejects_redirect_without_location(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(302)))
with pytest.raises(FetchError, match="Location"):
fetch_url("https://example.org/start", client=client)
client.close()
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from apps.sources.adapters.generic_html import GenericHtmlAdapter
def test_generic_html_fallback_extracts_core_fields():
html = Path("fixtures/pages/sample_generic_job.html").read_text(encoding="utf-8")
result = GenericHtmlAdapter().extract(html, url="https://careers.example.net/jobs/1")
assert result.jobs[0].title == "Workplace Engineer"
assert result.jobs[0].employer_name == "Example Health"
assert "Microsoft Intune" in result.jobs[0].description_text
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import pytest
from django.core.management import CommandError
from apps.jobs.models import GeocodeLocationLookup
from apps.jobs.services.geocoding import (
CsvGeocodeProvider,
GeoPoint,
LocationMatch,
LocationMatchResult,
import_csv_geodata,
parse_belgian_location_query,
resolve_location,
validate_csv_geodata,
)
@pytest.mark.django_db
def test_parse_belgian_location_query():
postal, municipality = parse_belgian_location_query("Gent, 9000")
assert postal == "9000"
assert municipality == "gent"
postal, municipality = parse_belgian_location_query("1050 Ixelles")
assert postal == "1050"
assert municipality == "ixelles"
postal, municipality = parse_belgian_location_query("Brussel")
assert postal is None
assert municipality == "brussel"
@pytest.mark.django_db
def test_geocode_provider_resolves_postal_and_municipality_exactly():
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="postal",
query_value="9000",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.0500"),
longitude=Decimal("3.7300"),
confidence=Decimal("0.90"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="gent",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.0500"),
longitude=Decimal("3.7300"),
confidence=Decimal("0.90"),
)
provider = CsvGeocodeProvider(source_name="fixture", source_version="2026-01-01")
result = resolve_location("9000 Gent", provider)
assert result.ambiguous is False
assert result.location is not None
assert result.location.postal_code == "9000"
assert result.location.municipality == "Gent"
assert result.location.point == GeoPoint(latitude=51.05, longitude=3.73)
@pytest.mark.django_db
def test_geocode_provider_marks_ambiguous_as_unknown():
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="brussel",
postal_code="1000",
municipality="Brussel",
region="Brussels Hoofdstedelijk Gewest",
latitude=Decimal("50.8500"),
longitude=Decimal("4.3500"),
confidence=Decimal("0.85"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="brussel",
postal_code="1000-200",
municipality="Brussel",
region="Brussels Hoofdstedelijk Gewest",
latitude=Decimal("50.8400"),
longitude=Decimal("4.3400"),
confidence=Decimal("0.85"),
)
provider = CsvGeocodeProvider(source_name="fixture", source_version="2026-01-01")
result = resolve_location("Brussel", provider)
assert result.ambiguous is True
assert result.location is None
@pytest.mark.django_db
def test_validate_csv_geodata_flags_duplicates_and_invalid_coordinates(tmp_path: Path):
source = tmp_path / "geodata.csv"
source.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,51.05,3.73\n"
"9000,Gent,Vlaams-Brabant,51.05,3.74\n"
)
with pytest.raises(CommandError, match="dubbel record"):
validate_csv_geodata(source)
source.write_text(
"postal_code,municipality,region,latitude,longitude\n9000,Gent,Vlaams-Brabant,99,3.73\n"
)
with pytest.raises(CommandError, match="latitude buiten bereik"):
validate_csv_geodata(source)
@pytest.mark.django_db
def test_import_csv_geodata_is_atomic_without_replace(tmp_path: Path):
source = tmp_path / "geodata.csv"
source.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,51.05,3.73\n"
"1040,Brussel,Brussels Hoofdstedelijk Gewest,50.85,4.35\n"
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="manual",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="gent",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.05"),
longitude=Decimal("3.73"),
confidence=Decimal("1.00"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="manual",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="postal",
query_value="9000",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.05"),
longitude=Decimal("3.73"),
confidence=Decimal("1.00"),
)
with pytest.raises(CommandError, match="overschrijven zonder --replace"):
import_csv_geodata(source, source_name="fixture", source_version="manual")
assert GeocodeLocationLookup.objects.count() == 2
def test_resolve_location_keeps_ambiguous_unknown() -> None:
class StubProvider:
def resolve(self, query: str):
return [
LocationMatch(
postal_code="1000",
municipality="Brussel",
region="Brussels",
point=None,
confidence=0.85,
source="csv",
source_version="manual",
metadata={},
),
LocationMatch(
postal_code="1001",
municipality="Brussel",
region="Brussels",
point=None,
confidence=0.85,
source="csv",
source_version="manual",
metadata={},
),
]
result = resolve_location("Brussel", StubProvider())
assert result.ambiguous is True
assert isinstance(result, LocationMatchResult)
assert result.location is None
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import pytest
from django.test import override_settings
from apps.core.health import readiness
pytestmark = pytest.mark.django_db
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
def test_readiness_checks_database_and_shared_cache():
result = readiness()
assert result.ok is True
assert result.checks == {"database": "ok", "cache": "ok"}
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
def test_readiness_reports_cache_failure(monkeypatch):
def fail_cache(*args, **kwargs):
raise ConnectionError
monkeypatch.setattr("apps.core.health.cache.set", fail_cache)
result = readiness()
assert result.ok is False
assert result.checks["database"] == "ok"
assert result.checks["cache"] == "error:ConnectionError"
+41
View File
@@ -0,0 +1,41 @@
from pathlib import Path
import pytest
from scripts.benchmark_import import UnsafeBenchmarkEnvironment, validate_benchmark_identity
def _database(name: str, host: str = "127.0.0.1") -> dict[str, str]:
return {
"ENGINE": "django.db.backends.postgresql",
"NAME": name,
"HOST": host,
}
def test_benchmark_identity_accepts_only_isolated_postgres() -> None:
validate_benchmark_identity(
_database("vacatureradar_bench_release_0314"), "https://benchmark.invalid"
)
@pytest.mark.parametrize(
("database", "public_url"),
[
(_database("vacatureradar"), "https://benchmark.invalid"),
(
_database("vacatureradar_bench_release", "database.internal"),
"https://benchmark.invalid",
),
(_database("vacatureradar_bench_release"), "https://vacatureradar.itworx.tech"),
(
{"ENGINE": "django.db.backends.sqlite3", "NAME": Path("bench.sqlite3"), "HOST": ""},
"https://benchmark.invalid",
),
],
)
def test_benchmark_identity_rejects_unsafe_targets(
database: dict[str, object], public_url: str
) -> None:
with pytest.raises(UnsafeBenchmarkEnvironment):
validate_benchmark_identity(database, public_url)
+15
View File
@@ -0,0 +1,15 @@
import json
from pathlib import Path
from apps.sources.adapters.jsonld import JsonLdJobPostingAdapter
def test_extracts_sample_jobposting():
html = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
expected = json.loads(Path("fixtures/golden/sample_jsonld_job.json").read_text())
result = JsonLdJobPostingAdapter().extract(html, url="https://jobs.example.org/source")
assert result.confidence >= 0.9
assert len(result.jobs) == 1
job = result.jobs[0]
for key, value in expected.items():
assert getattr(job, key) == value
+123
View File
@@ -0,0 +1,123 @@
import pytest
from cryptography.fernet import Fernet
from django.core.exceptions import ValidationError
from django.test import override_settings
from apps.sources.models import MailboxConnection
from apps.sources.services.imap_import import poll_imap_mailbox
from apps.sources.services.mailbox_connections import (
MailboxCredentialError,
decrypt_mailbox_password,
encrypt_mailbox_password,
rotate_mailbox_credentials,
)
from apps.sources.services.url_security import UnsafeUrlError
def test_mailbox_password_is_encrypted_and_supports_key_rotation():
old_key = Fernet.generate_key().decode("ascii")
new_key = Fernet.generate_key().decode("ascii")
with override_settings(MAILBOX_CREDENTIAL_KEYS=[old_key]):
ciphertext = encrypt_mailbox_password("app-password-value")
connection = MailboxConnection(encrypted_password=ciphertext)
assert "app-password-value" not in ciphertext
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key, old_key]):
assert decrypt_mailbox_password(connection) == "app-password-value"
rotated = encrypt_mailbox_password("replacement-value")
assert Fernet(new_key.encode("ascii")).decrypt(rotated.encode("ascii")) == b"replacement-value"
def test_mailbox_password_fails_closed_without_valid_key():
with (
override_settings(MAILBOX_CREDENTIAL_KEYS=[]),
pytest.raises(MailboxCredentialError, match="niet geconfigureerd"),
):
encrypt_mailbox_password("never-stored")
with (
override_settings(MAILBOX_CREDENTIAL_KEYS=["invalid"]),
pytest.raises(MailboxCredentialError, match="ongeldige sleutel"),
):
encrypt_mailbox_password("never-stored")
@pytest.mark.parametrize("host", ["", "https://imap.example.org", "imap.example.org/path", "a b"])
def test_custom_mailbox_host_rejects_non_hostname_values(host):
connection = MailboxConnection(
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.CUSTOM,
custom_host=host,
port=993,
username="mailbox@example.invalid",
encrypted_password="ciphertext",
)
with pytest.raises(ValidationError):
connection.clean()
@pytest.mark.parametrize("port", [0, 65536])
def test_mailbox_rejects_invalid_port(port):
connection = MailboxConnection(
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.GMAIL,
port=port,
username="mailbox@example.invalid",
encrypted_password="ciphertext",
)
with pytest.raises(ValidationError):
connection.clean()
@pytest.mark.django_db
def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
old_key = Fernet.generate_key().decode("ascii")
new_key = Fernet.generate_key().decode("ascii")
with override_settings(MAILBOX_CREDENTIAL_KEYS=[old_key]):
ciphertext = encrypt_mailbox_password("rotate-me")
connection = MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.GMAIL,
username="rotate@example.invalid",
encrypted_password=ciphertext,
)
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key, old_key]):
assert rotate_mailbox_credentials() == 1
connection.refresh_from_db()
with override_settings(MAILBOX_CREDENTIAL_KEYS=[new_key]):
assert decrypt_mailbox_password(connection) == "rotate-me"
@pytest.mark.django_db
def test_rotation_skips_outlook_oauth_connections(user):
MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.INDEED,
provider=MailboxConnection.Provider.OUTLOOK,
username="vacatureradar@example.invalid",
encrypted_password="",
)
assert rotate_mailbox_credentials() == 0
@pytest.mark.django_db
def test_private_imap_host_is_blocked_before_connection(user):
key = Fernet.generate_key().decode("ascii")
with override_settings(MAILBOX_CREDENTIAL_KEYS=[key]):
connection = MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.VDAB,
provider=MailboxConnection.Provider.CUSTOM,
custom_host="127.0.0.1",
username="private@example.invalid",
encrypted_password=encrypt_mailbox_password("never-sent"),
)
with pytest.raises(UnsafeUrlError, match="Niet-publiek"):
poll_imap_mailbox(
connection,
client_factory=lambda *args, **kwargs: pytest.fail(
"Private IMAP-host had niet geconnecteerd mogen worden"
),
)
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import pytest
from apps.sources.models import Source, SourceRun
from apps.sources.services.fetcher import FetchedDocument
from apps.sources.services.manual_import import ManualImportError, import_manual_source
def test_manual_import_url_uses_fetch_and_succeeds(monkeypatch, user):
def fake_fetch(*_args, **_kwargs):
return FetchedDocument(
requested_url="https://jobs.example.org/jobs",
final_url="https://jobs.example.org/jobs",
status_code=200,
headers={
"content-type": "text/html; charset=utf-8",
"etag": "abc",
"last-modified": "Mon, 01 Jan 2000 00:00:00 GMT",
},
content=b"<html><body>vacature</body></html>",
)
def fake_process(document):
return {
"extracted": 2,
"created": 1,
"updated": 1,
"duplicates": 0,
"parser": "manual-test",
"warnings": [],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fake_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
summary = import_manual_source(actor=user, source_url="https://jobs.example.org/jobs")
source = Source.objects.get(pk=summary.source_id)
assert summary.mode == "url"
assert summary.extracted_count == 2
assert summary.created_count == 1
assert source.source_type == Source.Type.MANUAL
assert source.domain == "jobs.example.org"
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SUCCESS).exists()
def test_manual_import_paste_text_sanitizes_and_skips_fetch(monkeypatch, user):
def fail_fetch(*_args, **_kwargs):
raise AssertionError("Fetcher should not be called for pasted text.")
def fake_process(document):
return {
"extracted": 0,
"created": 0,
"updated": 0,
"duplicates": 0,
"parser": "manual-test",
"warnings": ["noop"],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fail_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
summary = import_manual_source(
actor=user, pasted_text="<script>alert('x')</script>\nVacaturetekst"
)
source = Source.objects.get(pk=summary.source_id)
assert summary.mode == "paste"
assert source.source_type == Source.Type.MANUAL
assert source.base_url.startswith("https://manual-")
def test_manual_import_rejects_denied_domain(user):
with pytest.raises(ManualImportError):
import_manual_source(actor=user, source_url="https://linkedin.com/jobs/123")
def test_manual_import_rejects_paste_payload_when_too_large(user, settings):
settings.MANUAL_IMPORT_PASTE_MAX_BYTES = 12
huge_text = "a" * 20
with pytest.raises(ManualImportError):
import_manual_source(actor=user, pasted_text=huge_text)
+108
View File
@@ -0,0 +1,108 @@
import base64
import pytest
from django.test import override_settings
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.notifications.backends import Microsoft365OAuthEmailBackend
@override_settings(
M365_TENANT_ID="tenant-id",
M365_CLIENT_ID="client-id",
M365_CLIENT_SECRET="client-secret",
M365_EXCHANGE_SCOPE="https://outlook.office365.com/.default",
)
def test_app_only_token_uses_tenant_authority_and_exchange_scope():
calls = {}
class Client:
def acquire_token_for_client(self, *, scopes):
calls["scopes"] = scopes
return {"access_token": "access-token"}
def factory(client_id, *, authority, client_credential):
calls.update(
client_id=client_id,
authority=authority,
client_credential=client_credential,
)
return Client()
assert acquire_exchange_access_token(client_factory=factory) == "access-token"
assert calls == {
"client_id": "client-id",
"authority": "https://login.microsoftonline.com/tenant-id",
"client_credential": "client-secret",
"scopes": ["https://outlook.office365.com/.default"],
}
@override_settings(M365_TENANT_ID="", M365_CLIENT_ID="", M365_CLIENT_SECRET="")
def test_missing_oauth_configuration_fails_without_secret_material():
with pytest.raises(Microsoft365OAuthError, match="niet volledig") as error:
acquire_exchange_access_token()
assert "secret" not in str(error.value).lower()
def test_xoauth2_payload_is_encoded_exactly():
encoded = xoauth2_b64("mailbox@example.invalid", "token-value")
assert base64.b64decode(encoded) == (
b"user=mailbox@example.invalid\x01auth=Bearer token-value\x01\x01"
)
@override_settings(
M365_MAILBOX_USER="vacatureradar@example.invalid",
EMAIL_HOST="smtp.office365.com",
EMAIL_PORT=587,
DEFAULT_FROM_EMAIL="VacatureRadar <vacatureradar@example.invalid>",
M365_SMTP_TIMEOUT_SECONDS=10,
)
def test_oauth_smtp_backend_authenticates_and_sends(monkeypatch):
calls = []
class SMTP:
def __init__(self, host, port, *, timeout):
calls.append(("connect", host, port, timeout))
def __enter__(self):
return self
def __exit__(self, *args):
return None
def ehlo(self):
calls.append(("ehlo",))
def starttls(self, *, context):
calls.append(("starttls", bool(context)))
def docmd(self, command, argument):
calls.append((command, argument))
return 235, b"ok"
def sendmail(self, sender, recipients, message):
calls.append(("sendmail", sender, recipients, message))
monkeypatch.setattr("apps.notifications.backends.smtplib.SMTP", SMTP)
monkeypatch.setattr(
"apps.notifications.backends.acquire_exchange_access_token",
lambda: "token-value",
)
from django.core.mail import EmailMessage as DjangoEmailMessage
django_message = DjangoEmailMessage(
subject="Test",
body="test",
from_email="vacatureradar@example.invalid",
to=["recipient@example.invalid"],
)
assert Microsoft365OAuthEmailBackend().send_messages([django_message]) == 1
assert calls[0] == ("connect", "smtp.office365.com", 587, 10)
assert any(call[0] == "AUTH" and call[1].startswith("XOAUTH2 ") for call in calls)
assert any(call[0] == "sendmail" for call in calls)
+45
View File
@@ -0,0 +1,45 @@
from unittest.mock import Mock, patch
import pytest
from django.http import HttpResponse
from django.test import RequestFactory
from apps.core.middleware import MigrationMaintenanceMiddleware
@pytest.mark.parametrize("method,path,allowed", [
("GET", "/health/ready/", True),
("HEAD", "/health/live/", True),
("POST", "/health/ready/", False),
("GET", "/health/ready", False),
("GET", "/", False),
("POST", "/accounts/login/", False),
("OPTIONS", "/health/live/", False),
])
def test_held_candidate_only_allows_exact_safe_health(method, path, allowed):
downstream = Mock(return_value=HttpResponse("ok"))
with patch("apps.core.middleware.hold_active", return_value=True):
response = MigrationMaintenanceMiddleware(downstream)(
RequestFactory().generic(method, path)
)
assert response.status_code == (200 if allowed else 503)
assert downstream.called is allowed
if not allowed:
assert response["Cache-Control"] == "no-store"
@pytest.mark.parametrize("failure", [PermissionError(), RuntimeError()])
def test_marker_errors_block_ordinary_traffic(failure):
downstream = Mock()
with patch("apps.core.middleware.hold_active", side_effect=failure):
response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().get("/"))
assert response.status_code == 503
downstream.assert_not_called()
def test_normal_runtime_unchanged_and_middleware_is_first(settings):
assert settings.MIDDLEWARE[0] == "apps.core.middleware.MigrationMaintenanceMiddleware"
downstream = Mock(return_value=HttpResponse("ok"))
with patch("apps.core.middleware.hold_active", return_value=False):
response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().post("/"))
assert response.status_code == 200
+44
View File
@@ -0,0 +1,44 @@
import importlib.util
from pathlib import Path
import pytest
spec = importlib.util.spec_from_file_location(
"migration_worker_gate",
Path(__file__).resolve().parents[2] / "scripts/migration_worker_gate.py",
)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
@pytest.mark.parametrize("role", ["worker", "scheduler"])
def test_hold_prevents_outbound_process_until_release(role):
states = iter([True, True, False])
events = []
gate.start(
role,
check=lambda: next(states),
sleep=lambda seconds: events.append("wait"),
execute=lambda executable, args: events.append(args),
)
assert events == ["wait", "wait", gate.COMMANDS[role]]
def test_normal_startup_and_missing_hold(tmp_path):
assert not gate.hold_active(tmp_path / "absent")
events = []
gate.start("worker", check=lambda: False, execute=lambda executable, args: events.append(args))
assert events == [gate.COMMANDS["worker"]]
def test_hold_exists_and_unsupported_marker_fails_closed(tmp_path):
marker = tmp_path / "hold"
marker.write_text("fixture-attempt")
assert gate.hold_active(marker)
with pytest.raises(RuntimeError):
gate.hold_active(tmp_path)
def test_unknown_role_never_executes():
with pytest.raises(ValueError):
gate.start("shell", execute=lambda *args: pytest.fail("unreviewed command"))
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from django.test import RequestFactory, override_settings
from apps.core.network import client_ip
def _request(remote: str, forwarded: str = ""):
request = RequestFactory().get("/")
request.META["REMOTE_ADDR"] = remote
if forwarded:
request.META["HTTP_X_FORWARDED_FOR"] = forwarded
return request
@override_settings(TRUSTED_PROXY_CIDRS=[])
def test_client_ip_ignores_untrusted_forwarded_header():
assert client_ip(_request("203.0.113.20", "198.51.100.7")) == "203.0.113.20"
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
def test_client_ip_uses_forwarded_chain_only_from_trusted_proxy():
request = _request("172.18.0.4", "198.51.100.7, 172.18.0.3")
assert client_ip(request) == "198.51.100.7"
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
def test_client_ip_skips_invalid_forwarded_values():
request = _request("172.18.0.4", "invalid, 198.51.100.9")
assert client_ip(request) == "198.51.100.9"
+15
View File
@@ -0,0 +1,15 @@
from apps.jobs.services.normalization import (
infer_language,
normalize_employment_types,
normalize_title,
)
def test_title_and_employment_normalization():
assert normalize_title("Senior System Engineer (m/v/x) - Fulltime") == "senior system engineer"
assert normalize_employment_types(["FULL_TIME", "vast"]) == ["full_time", "permanent"]
def test_language_detection_is_conservative():
assert infer_language("Je beheert de infrastructuur en werkt met een team") == "nl"
assert infer_language("You manage the infrastructure and work with a team") == "en"
+74
View File
@@ -0,0 +1,74 @@
from decimal import Decimal
import pytest
from django.core.exceptions import ValidationError
from apps.profiles.models import ProfileRevision, SearchProfile
from apps.profiles.services import apply_feedback_delta, save_profile_revision
@pytest.mark.django_db
def test_profile_validation_snapshot_activation_and_revisions(user, profile):
profile.full_clean()
snapshot = profile.snapshot()
assert snapshot["home_municipality"] == "Hasselt"
assert snapshot["home_latitude"] == pytest.approx(50.9307)
assert snapshot["experience_years"] == 0
assert "seniority" not in profile.weights
assert sum(profile.weights.values()) == 100
other = SearchProfile.objects.create(user=user, name="Tweede", is_active=False)
other.activate()
profile.refresh_from_db()
other.refresh_from_db()
assert other.is_active is True
assert profile.is_active is False
revision = save_profile_revision(other, reason="test")
assert revision.version == 1
assert ProfileRevision.objects.filter(profile=other).count() == 1
second = save_profile_revision(other, reason="test-2")
assert second.version == 2
other.refresh_from_db()
assert other.version == 2
@pytest.mark.django_db
def test_feedback_delta_is_bounded_and_can_be_disabled(profile):
apply_feedback_delta(profile, "content", 100)
profile.refresh_from_db()
assert profile.weights["content"] == 40.0
apply_feedback_delta(profile, "content", -100)
profile.refresh_from_db()
assert profile.weights["content"] == 0.0
profile.learning_enabled = False
profile.weights["skills"] = 20.0
profile.save()
apply_feedback_delta(profile, "skills", 5)
profile.refresh_from_db()
assert profile.weights["skills"] == 20.0
@pytest.mark.parametrize(
("field", "value"),
[
("weights", {"content": "not-a-number"}),
("weights", {"content": -1}),
("home_latitude", Decimal("91")),
("home_longitude", Decimal("181")),
],
)
@pytest.mark.django_db
def test_profile_rejects_invalid_values(profile, field, value):
setattr(profile, field, value)
with pytest.raises(ValidationError):
profile.full_clean()
@pytest.mark.django_db
def test_profile_rejects_inverted_thresholds(profile):
profile.recommendation_threshold = 91
profile.top_match_threshold = 90
with pytest.raises(ValidationError):
profile.full_clean()
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
from django.core.exceptions import ImproperlyConfigured
from config.settings import normalize_public_base_url
from scripts.configure_public_url import ConfigurationError, configure_env, normalize_public_url
@pytest.mark.parametrize(
("raw", "origin", "host"),
[
(
"https://vacatureradar.itworx.tech/",
"https://vacatureradar.itworx.tech",
"vacatureradar.itworx.tech",
),
("https://jobs.example.be:8443", "https://jobs.example.be:8443", "jobs.example.be"),
("http://127.0.0.1:1226", "http://127.0.0.1:1226", "127.0.0.1"),
],
)
def test_public_url_normalization(raw, origin, host):
assert normalize_public_base_url(raw) == (origin, host)
assert normalize_public_url(raw) == (origin, host)
@pytest.mark.parametrize(
"raw",
[
"vacatureradar.itworx.tech",
"ftp://vacatureradar.itworx.tech",
"https://user:secret@example.org",
"https://example.org/app",
"https://example.org?debug=1",
],
)
def test_public_url_rejects_unsafe_or_unsupported_values(raw):
with pytest.raises((ImproperlyConfigured, ConfigurationError)):
normalize_public_base_url(raw)
def test_configure_public_url_updates_only_public_security_settings(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text(
"DJANGO_SECRET_KEY=keep-this-secret\n"
"POSTGRES_PASSWORD=keep-this-password\n"
"DJANGO_ALLOWED_HOSTS=vacatureradar.local,localhost\n"
"DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226\n",
encoding="utf-8",
)
changed = configure_env(
env_file,
"https://vacatureradar.itworx.tech/",
hsts_seconds=300,
cache_url="redis://127.0.0.1:6379/1",
trusted_proxy_cidrs=["172.18.0.0/16"],
)
content = env_file.read_text(encoding="utf-8")
assert "PUBLIC_BASE_URL=https://vacatureradar.itworx.tech" in content
assert "DJANGO_DEBUG=0" in content
assert "vacatureradar.itworx.tech" in content
assert (
"DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226,https://vacatureradar.itworx.tech"
) in content
assert "CACHE_URL=redis://127.0.0.1:6379/1" in content
assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content
assert "DJANGO_SECRET_KEY=keep-this-secret" in content
assert "POSTGRES_PASSWORD=keep-this-password" in content
if os.name != "nt":
assert env_file.stat().st_mode & 0o777 == 0o600
assert "DJANGO_SECRET_KEY" not in changed
def test_configure_public_url_rejects_plain_http(tmp_path: Path):
with pytest.raises(ConfigurationError, match="HTTPS"):
configure_env(tmp_path / ".env", "http://vacatureradar.example.be")
def test_public_base_url_populates_django_host_and_csrf_settings():
environment = os.environ.copy()
environment.update(
{
"DJANGO_SECRET_KEY": "aB3!" * 16,
"DJANGO_DEBUG": "0",
"PUBLIC_BASE_URL": "https://vacatureradar.itworx.tech",
"DEMO_MODE_ENABLED": "0",
"SECURE_HSTS_SECONDS": "300",
}
)
result = subprocess.run( # noqa: S603 - fixed interpreter and static assertion program
[
sys.executable,
"-c",
(
"import config.settings as settings; "
"assert 'vacatureradar.itworx.tech' in settings.ALLOWED_HOSTS; "
"assert 'https://vacatureradar.itworx.tech' "
"in settings.CSRF_TRUSTED_ORIGINS; "
"assert settings.DEBUG is False; "
"assert settings.SESSION_COOKIE_SECURE is True; "
"assert settings.CSRF_COOKIE_SECURE is True; "
"assert settings.SECURE_SSL_REDIRECT is True"
),
],
cwd=Path(__file__).resolve().parents[2],
env=environment,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_configure_public_url_is_idempotent(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text("DJANGO_ALLOWED_HOSTS=localhost\n", encoding="utf-8")
for _ in range(2):
configure_env(env_file, "https://vacatureradar.example.be")
content = env_file.read_text(encoding="utf-8")
assert content.count("PUBLIC_BASE_URL=") == 1
assert content.count("vacatureradar.example.be") == 3
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from django.core.cache import cache
from django.test import RequestFactory
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
@pytest.fixture(autouse=True)
def _clear_rate_limit_cache():
cache.clear()
yield
cache.clear()
def _post_request(payload: dict[str, str], *, username: str | None = None):
factory = RequestFactory()
request = factory.post("/login/", data=payload)
request.META["REMOTE_ADDR"] = "203.0.113.10"
if username is None:
request.user = SimpleNamespace(is_authenticated=False, is_anonymous=True)
else:
request.user = SimpleNamespace(is_authenticated=True, pk=42)
payload["username"] = username
request.user.username = username
return request
def test_rate_limit_blocks_after_max_attempts_and_reports_block_state():
request = _post_request({"username": "alice"}, username="alice")
state = is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 0
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 1
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert state.is_blocked
assert state.remaining_seconds > 0
assert is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
).is_blocked
def test_rate_limit_clear_resets_state_for_authenticated_identity():
request = _post_request({"username": "alice"}, username="alice")
state = register_rate_limit_failure(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert state.is_blocked
clear_rate_limit(request, namespace="login")
state = is_rate_limited(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert not state.is_blocked
assert state.attempts == 0
+253
View File
@@ -0,0 +1,253 @@
from pathlib import Path
import pytest
from apps.sources.adapters.kempen import KempenEmployerAdapter
from apps.sources.adapters.mol_region import MolRegionEmployerAdapter
from apps.sources.adapters.regional import CordaCampusAdapter, LocalEmployerListingAdapter
def test_corda_adapter_extracts_local_employer_cards_and_rejects_off_domain_links():
content = Path("fixtures/pages/corda-campus-jobs.html").read_text(encoding="utf-8")
result = CordaCampusAdapter().extract(
content,
url="https://www.cordacampus.com/jobs/",
)
assert result.parser_key == "regional-corda-campus"
assert len(result.jobs) == 2
assert {job.title for job in result.jobs} == {"System Engineer", "Data Engineer"}
assert {job.employer_name for job in result.jobs} == {
"Voorbeeld IT",
"Voorbeeld Data",
}
assert all(job.region == "Limburg" for job in result.jobs)
assert all("evil.example" not in job.url for job in result.jobs)
def test_corda_adapter_does_not_capture_individual_detail_pages():
result = CordaCampusAdapter().extract(
"<h1>System Engineer</h1>",
url="https://www.cordacampus.com/job/system-engineer/",
)
assert result.jobs == []
assert result.confidence == 0.0
@pytest.mark.parametrize(
("fixture_name", "url", "expected_title", "expected_employer"),
[
(
"aca-group-jobs.html",
"https://acagroup.be/en/jobs",
"AWS Cloud & DevOps Engineer",
"ACA Group",
),
(
"xplore-group-jobs.html",
"https://www.xploregroup.be/en/jobs",
"Platform Engineer",
"Xplore Group",
),
(
"uhasselt-jobs.html",
"https://www.uhasselt.be/vacatures",
"Expert systeembeheerder (100%)",
"Universiteit Hasselt",
),
(
"pxl-jobs.html",
"https://ses.pxl.be/",
"Lector netwerkbeheer",
"Hogeschool PXL",
),
(
"zol-jobs.html",
"https://ziekenhuis-oost-limburg.cvw.io/?lang=nl-BE",
"(Senior) IT Engineer Infrastructuur",
"Ziekenhuis Oost-Limburg",
),
],
)
def test_local_employer_adapter_extracts_reviewed_routes_only(
fixture_name, url, expected_title, expected_employer
):
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
result = LocalEmployerListingAdapter().extract(content, url=url)
assert result.parser_key == "regional-local-employers"
assert len(result.jobs) == 1
assert result.jobs[0].title == expected_title
assert result.jobs[0].employer_name == expected_employer
assert result.jobs[0].region == "Limburg"
assert "evil.example" not in result.jobs[0].url
def test_local_employer_adapter_fails_closed_for_unreviewed_host_or_path():
adapter = LocalEmployerListingAdapter()
assert adapter.extract("<h3>Fake</h3>", url="https://evil.example/jobs").jobs == []
assert (
adapter.extract(
"<h3>Detail</h3>",
url="https://www.uhasselt.be/vacatures/detail/3999",
).jobs
== []
)
@pytest.mark.parametrize(
("fixture_name", "url", "expected_title", "expected_employer"),
[
(
"sck-cen-jobs.html",
"https://www.sckcen.be/nl/carriere/vacatures",
"Automatisatie Ingenieur PLC & OT-netwerken",
"SCK CEN",
),
(
"cipal-schaubroeck-jobs.html",
"https://cipalschaubroeck.teamtailor.com/jobs",
"Network & Security Engineer",
"Cipal Schaubroeck",
),
(
"vanroey-jobs.html",
"https://www.vanroey.be/en/job-overview/",
"Network Engineer",
"VanRoey",
),
(
"netropolix-jobs.html",
"https://netropolix.recruitee.com/",
"System Engineer - Geel",
"NTX (Netropolix)",
),
(
"thomas-more-jobs.html",
"https://jobpage.cvwarehouse.com/?companyGuid=eab9ca13-ee10-4504-8a87-a785d0b037ef&lang=nl-BE",
"Een medewerker lokale IT - Campus Geel",
"Thomas More",
),
],
)
def test_mol_region_adapter_extracts_only_reviewed_local_cards(
fixture_name, url, expected_title, expected_employer
):
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
result = MolRegionEmployerAdapter().extract(content, url=url)
assert result.parser_key == "regional-mol-employers"
assert result.warnings == []
assert len(result.jobs) == 1
assert result.jobs[0].title == expected_title
assert result.jobs[0].employer_name == expected_employer
assert result.jobs[0].region == "Antwerpen"
assert "evil.example" not in result.jobs[0].url
def test_mol_region_adapter_fails_closed_for_unreviewed_route_and_company():
adapter = MolRegionEmployerAdapter()
assert adapter.extract("<h1>Fake</h1>", url="https://evil.example/jobs").jobs == []
assert (
adapter.extract(
"<h1>Detail</h1>",
url="https://www.sckcen.be/nl/carriere/vacatures/detail",
).jobs
== []
)
@pytest.mark.parametrize(
("fixture_name", "url", "expected_title", "expected_employer", "expected_postal"),
[
(
"ziekenhuis-geel-jobs.html",
"https://ziekenhuisgeel.careersite.be/nl/vacatures",
"ICT Support Engineer",
"Ziekenhuis Geel",
"2440",
),
(
"stad-geel-jobs.html",
"https://geel.hro.be/",
"Administratief medewerker digitale dienstverlening",
"Lokaal bestuur Geel",
"2440",
),
(
"stad-turnhout-jobs.html",
"https://jobs.turnhout.be/",
"Coördinator digitale projecten",
"Stad Turnhout",
"2300",
),
(
"renotec-geel-jobs.html",
"https://jobs.renotec.be/nl/alle-jobs/",
"Technisch commercieel verantwoordelijke",
"Group Renotec",
"2440",
),
(
"ravago-kempen-jobs.html",
"https://ravago.softgarden.io/en/vacancies",
"SAP Operations Analyst",
"Ravago",
"2370",
),
(
"sanofi-geel-jobs.html",
"https://jobs.sanofi.com/en/belgium",
"Maintenance Engineer",
"Sanofi",
"2440",
),
(
"daf-westerlo-jobs.html",
"https://www.daf.com/nl-nl/werken-bij-daf/vacatures",
"Technieker Facilitaire Diensten",
"DAF Trucks",
"2260",
),
],
)
def test_kempen_adapter_extracts_only_reviewed_local_cards(
fixture_name, url, expected_title, expected_employer, expected_postal
):
content = Path(f"fixtures/pages/{fixture_name}").read_text(encoding="utf-8")
result = KempenEmployerAdapter().extract(content, url=url)
assert result.parser_key == "regional-kempen-employers"
assert result.warnings == []
assert len(result.jobs) == 1
assert result.jobs[0].title == expected_title
assert result.jobs[0].employer_name == expected_employer
assert result.jobs[0].postal_code == expected_postal
assert "evil.example" not in result.jobs[0].url
def test_kempen_adapter_fails_closed_for_unreviewed_route():
adapter = KempenEmployerAdapter()
assert adapter.extract("<h1>Fake</h1>", url="https://evil.example/jobs").jobs == []
assert (
adapter.extract(
"<h1>Detail</h1>",
url="https://jobs.sanofi.com/en/search-jobs",
).jobs
== []
)
assert (
adapter.extract(
"<h1>Andere werkgever</h1>",
url="https://jobpage.cvwarehouse.com/?companyGuid=other&lang=nl-BE",
).jobs
== []
)
+74
View File
@@ -0,0 +1,74 @@
import pytest
from apps.jobs.models import JobPosting
from apps.jobs.services.relevance import (
assess_it_relevance,
it_relevance_query,
profile_requires_it_focus,
)
def test_title_led_it_relevance_accepts_clear_it_roles():
for title in (
"Modern Workplace Engineer",
"Product owner ICT (100%)",
"Functioneel Analist",
"Senior .Net Developer",
"Senior Mobile Developer",
"Deeltijds docent informatica, networking & security",
):
assessment = assess_it_relevance(title)
assert assessment.relevant, title
assert assessment.signals
def test_title_led_it_relevance_rejects_non_it_and_ambiguous_roles():
for title in (
"Technieker Facilitaire Diensten",
"Spontane sollicitatie - logopedist",
"Doctoraatsbursaal trombo-inflammatie bij hemodialyse",
"Stafmedewerker Onderwijs faculteit Rechten",
"Project Manager",
"Business Developer",
"IT Recruitment Specialist",
):
assessment = assess_it_relevance(title)
assert not assessment.relevant, title
assert assessment.reason
def test_ambiguous_field_role_requires_strong_it_context():
accepted = assess_it_relevance(
"Field Service Engineer",
"Installatie van Microsoft 365, Windows Server en netwerkswitches bij klanten.",
)
rejected = assess_it_relevance(
"Field Service Engineer",
"Onderhoud en herstelling van industriële HVAC- en koelinstallaties.",
)
assert accepted.relevant
assert "vacaturetekst" in accepted.reason
assert not rejected.relevant
assert "Ambigue technische functietitel" in rejected.reason
@pytest.mark.django_db
def test_database_it_query_matches_contextual_assessment(job):
job.original_title = "Field Service Engineer"
job.description_text = "Implementeer Intune, Windows Server en VPN-oplossingen bij klanten."
job.save(update_fields=["original_title", "description_text"])
assert JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
job.description_text = "Onderhoud industriële pompen en productielijnen."
job.save(update_fields=["description_text"])
assert not JobPosting.objects.filter(it_relevance_query(), pk=job.pk).exists()
def test_active_it_profile_requires_deterministic_it_focus(profile):
assert profile_requires_it_focus(profile)
profile.name = "Algemeen profiel"
profile.desired_titles = ["verpleegkundige"]
profile.desired_skills = []
assert not profile_requires_it_focus(profile)
+27
View File
@@ -0,0 +1,27 @@
from django.core.cache import cache
from django.test import override_settings
from apps.core.services.automation import _runtime_heartbeat_state
from apps.core.tasks import RUNTIME_HEARTBEAT_KEY, record_runtime_heartbeat
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
def test_runtime_heartbeat_reports_real_worker_execution():
cache.delete(RUNTIME_HEARTBEAT_KEY)
assert _runtime_heartbeat_state()["healthy"] is False
timestamp = record_runtime_heartbeat()
state = _runtime_heartbeat_state()
assert cache.get(RUNTIME_HEARTBEAT_KEY) == timestamp
assert state["healthy"] is True
assert state["label"].startswith("Actief")
@override_settings(CELERY_TASK_ALWAYS_EAGER=True)
def test_runtime_heartbeat_is_honest_in_eager_mode():
assert _runtime_heartbeat_state() == {
"label": "Eager/lokaal",
"healthy": True,
"observed_at": None,
}
+85
View File
@@ -0,0 +1,85 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.sources.models import Source, SourceLease
from apps.sources.services.scheduling import (
acquire_source_lease,
calculate_failure_backoff_seconds,
release_source_lease,
)
@pytest.mark.django_db
def test_source_lease_blocks_concurrent_workers_and_recovers_when_expired(source):
now = timezone.now()
lease_a = acquire_source_lease(source_id=source.pk, worker_token="worker-a", now=now)
assert lease_a is not None
assert acquire_source_lease(source_id=source.pk, worker_token="worker-b", now=now) is None
assert release_source_lease(source_id=source.pk, worker_token="worker-a", now=now)
lease_b = acquire_source_lease(
source_id=source.pk,
worker_token="worker-b",
now=now + timedelta(seconds=source.minimum_interval_seconds),
)
assert lease_b is not None
@pytest.mark.django_db
def test_source_lease_reuses_expired_lease(db, source):
now = timezone.now()
SourceLease.objects.create(
source=source,
token="expired-worker",
worker_id="expired-worker",
expires_at=now - timedelta(minutes=1),
)
lease = acquire_source_lease(source_id=source.pk, worker_token="recovery-worker", now=now)
assert lease is not None
assert lease.worker_id == "recovery-worker"
@pytest.mark.django_db
def test_source_lease_allows_origin_concurrency(db):
shared_domain = "shared.example.org"
source_a = Source.objects.create(
name="Source A",
source_type=Source.Type.EMPLOYER,
base_url="https://shared.example.org/jobs/",
domain=shared_domain,
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
max_concurrency=2,
)
source_b = Source.objects.create(
name="Source B",
source_type=Source.Type.RSS,
base_url="https://shared.example.org/careers/",
domain=shared_domain,
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
max_concurrency=2,
)
now = timezone.now()
lease_a = acquire_source_lease(source_id=source_a.pk, worker_token="worker-a", now=now)
lease_b = acquire_source_lease(source_id=source_b.pk, worker_token="worker-b", now=now)
assert lease_a is not None
assert lease_b is not None
@pytest.mark.django_db
def test_failure_backoff_uses_retry_after_and_bounds():
source = Source(
name="Temp",
source_type=Source.Type.EMPLOYER,
base_url="https://retry.example.org/",
domain="retry.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
source.save()
backoff = calculate_failure_backoff_seconds(source, failure_count=2, retry_after_seconds=120)
assert backoff >= 120
assert backoff <= 3600 + 45
+299
View File
@@ -0,0 +1,299 @@
from __future__ import annotations
from decimal import Decimal
import pytest
from apps.jobs.models import AiAnalysisCache, Employer, JobPosting, ScoreRun
from apps.jobs.services import scoring
from apps.jobs.services.ai import AiAnalysis
from apps.jobs.services.scoring import calculate_score
@pytest.fixture
def matching_job(db):
employer = Employer.objects.create(
name="Example Public IT",
normalized_name="example public it",
domain="jobs.example.org",
is_direct_employer=True,
)
return JobPosting.objects.create(
employer=employer,
original_title="Infrastructure Engineer",
normalized_title="infrastructure engineer",
canonical_url="https://jobs.example.org/jobs/1",
canonical_key="a" * 64,
content_hash="b" * 64,
description_text=(
"Beheer Microsoft 365 en VMware. Hybride werk en beperkte tweedelijnssupport."
),
raw_location="Hasselt, Limburg",
region="Limburg",
municipality="Hasselt",
postal_code="3500",
latitude=Decimal("50.930700"),
longitude=Decimal("5.332500"),
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time", "permanent"],
skills_required=["Microsoft 365", "VMware"],
analysis_features={"support_ratio": 0.1, "public_sector_signal": 0.8},
direct_employer=True,
recruiter=False,
extraction_confidence=Decimal("0.95"),
status=JobPosting.Status.ACTIVE,
)
def test_matching_job_gets_recommendation(matching_job, profile):
result = calculate_score(matching_job, profile)
assert result.score >= profile.recommendation_threshold
assert result.recommendation in {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
}
assert not result.hard_exclusions
def test_broad_relevant_skill_profile_is_not_diluted_without_limit(matching_job, profile):
profile.desired_skills = [
"microsoft 365",
"vmware",
"windows server",
"active directory",
"intune",
"networking",
"veeam",
"powershell",
"3cx",
"zabbix",
]
result = calculate_score(matching_job, profile)
assert result.components["skills"] == 12.5
assert result.evidence["skills"]["configured_count"] == 10
assert result.evidence["skills"]["match_target"] == 4
assert not any("Niet duidelijk teruggevonden" in concern for concern in result.concerns)
def test_skill_aliases_match_common_product_names(matching_job, profile):
profile.desired_skills = ["microsoft 365", "entra id", "group policy", "vmware"]
matching_job.skills_required = []
matching_job.skills_preferred = []
matching_job.description_text = "Beheer M365, Azure AD, GPO en vSphere voor meerdere klanten."
result = calculate_score(matching_job, profile)
assert result.components["skills"] == 25.0
assert result.evidence["skills"]["matched"] == [
"entra id",
"group policy",
"microsoft 365",
"vmware",
]
def test_experience_never_changes_score_or_recommendation(matching_job, profile):
profile.experience_years = 10
matching_job.analysis_features = {
**matching_job.analysis_features,
"experience_years_max": 8,
}
experienced = calculate_score(matching_job, profile)
profile.experience_years = 3
less_experienced = calculate_score(matching_job, profile)
assert experienced.score == less_experienced.score
assert experienced.recommendation == less_experienced.recommendation
assert "seniority" not in experienced.components
assert "experience" not in experienced.evidence
assert not any("ervaring" in concern.lower() for concern in less_experienced.concerns)
def test_ai_seniority_label_is_informational_only():
base = {"support_ratio": 0.1, "consultancy_ratio": 0.2, "travel_ratio": 0.0}
junior = scoring._ai_feature_score({**base, "seniority": "junior"})
expert = scoring._ai_feature_score({**base, "seniority": "expert"})
assert junior == expert
def test_hard_title_exclusion_wins(matching_job, profile):
matching_job.original_title = "IT Sales Infrastructure Engineer"
matching_job.save(update_fields=["original_title"])
result = calculate_score(matching_job, profile)
assert result.recommendation == ScoreRun.Recommendation.HIDDEN
assert any("sales" in reason.lower() for reason in result.hard_exclusions)
def test_short_title_exclusion_uses_word_boundaries(matching_job, profile):
profile.excluded_titles = ["ai"]
matching_job.original_title = "Maintenance IT Engineer"
allowed = calculate_score(matching_job, profile)
matching_job.original_title = "AI Engineer"
excluded = calculate_score(matching_job, profile)
assert not any("Uitgesloten titelterm" in reason for reason in allowed.hard_exclusions)
assert any("Uitgesloten titelterm: ai" in reason for reason in excluded.hard_exclusions)
def test_title_exclusion_matches_simple_english_plural(matching_job, profile):
profile.excluded_titles = ["developer"]
matching_job.original_title = "Full Stack Developers"
result = calculate_score(matching_job, profile)
assert any("Uitgesloten titelterm: developer" in reason for reason in result.hard_exclusions)
def test_non_it_vacancy_is_hidden_for_it_profile(matching_job, profile):
matching_job.original_title = "Spontane sollicitatie - logopedist"
matching_job.normalized_title = "spontane sollicitatie logopedist"
matching_job.description_text = "Werk met het digitale patiëntendossier in ons ziekenhuis."
result = calculate_score(matching_job, profile)
assert result.recommendation == ScoreRun.Recommendation.HIDDEN
assert any("Geen aantoonbaar IT-signaal" in reason for reason in result.hard_exclusions)
assert result.evidence["it_relevance"]["relevant"] is False
def test_distance_boundary_inclusief(matching_job, profile, monkeypatch):
profile.max_distance_km = 50
profile.save(update_fields=["max_distance_km"])
monkeypatch.setattr(scoring, "haversine_km", lambda *_args, **_kwargs: 50.0)
matching_job.refresh_from_db()
matching_job.save(update_fields=["latitude", "longitude"])
result = calculate_score(matching_job, profile)
assert result.evidence["distance_km"] == 50.0
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
assert not any("boven maximum" in issue for issue in result.hard_exclusions)
def test_remote_job_heeft_geen_afstandsexclusie(matching_job, profile):
matching_job.workplace_type = JobPosting.Workplace.REMOTE
matching_job.postal_code = ""
matching_job.municipality = ""
matching_job.raw_location = ""
matching_job.save(
update_fields=["workplace_type", "postal_code", "municipality", "raw_location"]
)
matching_job.latitude = None
matching_job.longitude = None
result = calculate_score(matching_job, profile)
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
assert all("boven maximum" not in issue for issue in result.hard_exclusions)
def test_onbekende_locatie_leidt_niet_tot_automatische_uitsluiting(matching_job, profile):
matching_job.postal_code = ""
matching_job.municipality = ""
matching_job.raw_location = "onbekend"
matching_job.latitude = None
matching_job.longitude = None
matching_job.save(
update_fields=["postal_code", "municipality", "raw_location", "latitude", "longitude"]
)
result = calculate_score(matching_job, profile)
assert result.evidence["distance_km"] is None
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
def test_score_is_tijdzoneonafhankelijk(profile, user, matching_job):
utc_profile = profile
utc_profile.timezone = "UTC"
utc_profile.save(update_fields=["timezone"])
result_utc = calculate_score(matching_job, utc_profile)
ny_profile = profile.__class__.objects.create(
user=user,
name="UTC vergelijken",
is_active=False,
home_municipality=utc_profile.home_municipality,
home_latitude=utc_profile.home_latitude,
home_longitude=utc_profile.home_longitude,
max_distance_km=utc_profile.max_distance_km,
desired_titles=utc_profile.desired_titles,
excluded_titles=utc_profile.excluded_titles,
desired_skills=utc_profile.desired_skills,
excluded_skills=utc_profile.excluded_skills,
allowed_employment_types=utc_profile.allowed_employment_types,
preferred_workplace=utc_profile.preferred_workplace,
preferred_regions=utc_profile.preferred_regions,
excluded_regions=utc_profile.excluded_regions,
recommendation_threshold=utc_profile.recommendation_threshold,
top_match_threshold=utc_profile.top_match_threshold,
digest_time=utc_profile.digest_time,
quiet_hours_start=utc_profile.quiet_hours_start,
quiet_hours_end=utc_profile.quiet_hours_end,
learning_enabled=utc_profile.learning_enabled,
weights=utc_profile.weights,
timezone="America/New_York",
)
result_ny = calculate_score(matching_job, ny_profile)
assert result_ny.score == result_utc.score
def test_ai_score_influence_is_opt_in_and_bounded(matching_job, profile, monkeypatch):
profile.ai_scoring_enabled = True
profile.weights = {**profile.weights, "ai": 999}
profile.save(update_fields=["ai_scoring_enabled", "weights"])
monkeypatch.setattr(
scoring,
"analyze_job_text",
lambda *args, **kwargs: AiAnalysis(
features={
"support_ratio": 0.1,
"consultancy_ratio": 0.0,
"travel_ratio": 0.0,
"seniority": "senior",
"evidence": ["microsoft 365"],
},
summary_nl="Sterke technische rol.",
warnings=[],
model="local-model",
status=AiAnalysisCache.Status.OK,
error_category="",
cached=False,
),
)
result = calculate_score(matching_job, profile)
assert result.recommendation in {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
}
assert result.model_version == "local-model"
assert result.evidence["ai"]["status"] == AiAnalysisCache.Status.OK
assert result.evidence["ai"]["weight_applied"] == 20.0
assert "ai" in result.evidence
def test_ai_failure_keeps_deterministic_scoring(profile, matching_job, monkeypatch):
profile.ai_scoring_enabled = True
profile.weights = {**profile.weights, "ai": 10}
profile.save(update_fields=["ai_scoring_enabled", "weights"])
monkeypatch.setattr(
scoring,
"analyze_job_text",
lambda *args, **kwargs: AiAnalysis(
features={},
summary_nl="",
warnings=["service-timeout"],
model="local-model",
status=AiAnalysisCache.Status.TIMEOUT,
error_category="timeout",
cached=False,
),
)
result = calculate_score(matching_job, profile)
assert result.evidence["ai"]["status"] == AiAnalysisCache.Status.TIMEOUT
assert "AI-analyse" in "".join(result.concerns)
assert "components" in result.__dict__
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import pytest
from django.core.exceptions import ImproperlyConfigured
from config.settings import _validate_production_security, env_int_range
def test_env_int_range_accepts_default_and_rejects_invalid_values(monkeypatch):
monkeypatch.delenv("TEST_BOUNDED_SETTING", raising=False)
assert env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23) == 6
monkeypatch.setenv("TEST_BOUNDED_SETTING", "24")
with pytest.raises(ImproperlyConfigured):
env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23)
monkeypatch.setenv("TEST_BOUNDED_SETTING", "morgen")
with pytest.raises(ImproperlyConfigured):
env_int_range("TEST_BOUNDED_SETTING", 6, minimum=0, maximum=23)
def test_validate_production_security_skips_when_debug_mode_is_enabled():
_validate_production_security(
debug=True,
secret_key="dev-only-change-me",
allowed_hosts=[],
csrf_trusted_origins=[],
session_cookie_secure=False,
csrf_cookie_secure=False,
secure_ssl_redirect=False,
)
@pytest.mark.parametrize(
(
"secret_key",
"allowed_hosts",
"csrf_trusted_origins",
"session_cookie_secure",
"csrf_cookie_secure",
"secure_ssl_redirect",
),
[
("", ["example.org"], ["https://example.org"], True, True, True),
("change-me", ["example.org"], ["https://example.org"], True, True, True),
("too-short", ["example.org"], ["https://example.org"], True, True, True),
("a" * 64, [], ["https://example.org"], True, True, True),
("a" * 64, ["*"], ["https://example.org"], True, True, True),
("a" * 64, ["example.org"], [], True, True, True),
("a" * 64, ["example.org"], ["http://example.org"], True, True, True),
("a" * 64, ["example.org"], ["https://example.org"], False, True, True),
("a" * 64, ["example.org"], ["https://example.org"], True, False, True),
("a" * 64, ["example.org"], ["https://example.org"], True, True, False),
],
)
def test_validate_production_security_requires_secure_settings_for_prod(
secret_key,
allowed_hosts,
csrf_trusted_origins,
session_cookie_secure,
csrf_cookie_secure,
secure_ssl_redirect,
):
with pytest.raises(ImproperlyConfigured):
_validate_production_security(
debug=False,
secret_key=secret_key,
allowed_hosts=allowed_hosts,
csrf_trusted_origins=csrf_trusted_origins,
session_cookie_secure=session_cookie_secure,
csrf_cookie_secure=csrf_cookie_secure,
secure_ssl_redirect=secure_ssl_redirect,
)
def test_validate_production_security_rejects_writable_public_demo():
with pytest.raises(ImproperlyConfigured):
_validate_production_security(
debug=False,
secret_key="a" * 64,
allowed_hosts=["vacatureradar.itworx.tech"],
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
session_cookie_secure=True,
csrf_cookie_secure=True,
secure_ssl_redirect=True,
public_base_url="https://vacatureradar.itworx.tech",
demo_mode_enabled=True,
demo_read_only=False,
)
def test_validate_production_security_accepts_read_only_https_demo():
_validate_production_security(
debug=False,
secret_key="a" * 64,
allowed_hosts=["vacatureradar.itworx.tech"],
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
session_cookie_secure=True,
csrf_cookie_secure=True,
secure_ssl_redirect=True,
public_base_url="https://vacatureradar.itworx.tech",
demo_mode_enabled=True,
demo_read_only=True,
)
+80
View File
@@ -0,0 +1,80 @@
import pytest
from apps.jobs.models import JobSourceAlias, ScoreRun
from apps.jobs.services.skill_demand import build_skill_demand_report
from apps.sources.models import Source
@pytest.mark.django_db
def test_skill_demand_distinguishes_structured_evidence_and_text_signals(job, profile):
job.skills_required = ["Microsoft 365"]
job.skills_preferred = []
job.description_text = (
"Bouw governance voor Dynamics 365 en Microsoft Copilot. "
"Zorg ook voor high availability en gebruikersadoptie."
)
report = build_skill_demand_report(profile, jobs=[job])
by_key = {item.key: item for item in report.items}
assert by_key["microsoft 365"].covered is True
assert by_key["microsoft 365"].explicit_count == 1
assert by_key["dynamics 365"].covered is False
assert by_key["dynamics 365"].inferred_count == 1
assert by_key["microsoft copilot"].vacancy_count == 1
assert by_key["high availability"].learning_focus
assert report.gap_count >= 3
@pytest.mark.django_db
def test_skill_demand_filters_category_and_profile_coverage(job, profile):
job.skills_required = ["Microsoft 365", "FortiGate"]
job.description_text = "Microsoft 365 en FortiGate-beheer."
gaps = build_skill_demand_report(
profile,
category="netwerk-security",
coverage="gap",
jobs=[job],
)
assert [item.key for item in gaps.items] == ["fortinet"]
assert gaps.selected_category == "netwerk-security"
assert gaps.selected_coverage == "gap"
@pytest.mark.django_db
def test_generic_team_word_is_not_microsoft_teams_demand(job, profile):
job.skills_required = []
job.skills_preferred = []
job.description_text = "Je werkt samen met internationale teams aan klanttevredenheid."
report = build_skill_demand_report(profile, jobs=[job])
assert "microsoft teams" not in {item.key for item in report.items}
@pytest.mark.django_db
def test_disabled_source_vacancies_do_not_feed_learning_advice(job, profile, source):
JobSourceAlias.objects.create(
job=job,
source=source,
external_id="disabled-source-job",
url=job.canonical_url,
canonical_url=job.canonical_url,
)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=75,
confidence=0.9,
recommendation=ScoreRun.Recommendation.POSSIBLE,
)
source.status = Source.Status.DISABLED
source.save(update_fields=["status"])
report = build_skill_demand_report(profile)
assert report.total_jobs == 0
assert report.signal_count == 0
+67
View File
@@ -0,0 +1,67 @@
from datetime import timedelta
import pytest
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.utils import timezone
from apps.sources.models import Source, SourceRun
@pytest.mark.django_db
def test_source_validation_and_scheduling(source):
now = timezone.now()
source.schedule_after_success(now=now)
source.refresh_from_db()
assert source.failure_count == 0
assert source.next_run_at == now + timedelta(minutes=source.crawl_interval_minutes)
source.schedule_after_failure(now=now, backoff_minutes=30)
source.refresh_from_db()
assert source.failure_count == 1
assert source.next_run_at == now + timedelta(minutes=30)
source.base_url = "https://other.example.org/jobs"
with pytest.raises(ValidationError):
source.full_clean()
source.base_url = "https://jobs.example.org/jobs"
source.policy = Source.Policy.DENY
source.status = Source.Status.ACTIVE
with pytest.raises(ValidationError):
source.full_clean()
@pytest.mark.django_db
def test_source_run_finish_updates_metrics(source):
run = SourceRun.objects.create(source=source)
run.finish(SourceRun.Status.SUCCESS, http_status=200, extracted_count=3)
run.refresh_from_db()
assert run.status == SourceRun.Status.SUCCESS
assert run.finished_at is not None
assert run.http_status == 200
assert run.extracted_count == 3
@pytest.mark.django_db(transaction=True)
def test_shared_ats_domain_accepts_distinct_public_feeds_but_not_duplicate_urls():
Source.objects.create(
name="Lever A",
source_type=Source.Type.ATS,
base_url="https://api.lever.co/v0/postings/a?mode=json",
domain="api.lever.co",
)
Source.objects.create(
name="Lever B",
source_type=Source.Type.ATS,
base_url="https://api.lever.co/v0/postings/b?mode=json",
domain="api.lever.co",
)
with pytest.raises(IntegrityError):
Source.objects.create(
name="Lever A dubbel",
source_type=Source.Type.ATS,
base_url="https://api.lever.co/v0/postings/a?mode=json",
domain="api.lever.co",
)
+15
View File
@@ -0,0 +1,15 @@
import ssl
from pathlib import Path
import certifi
from apps.sources.services.tls import trusted_tls_context
def test_application_tls_context_uses_strict_pinned_ca_bundle():
context = trusted_tls_context()
assert Path(certifi.where()).is_file()
assert context.verify_mode == ssl.CERT_REQUIRED
assert context.check_hostname is True
assert context.cert_store_stats()["x509_ca"] > 0