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

This commit is contained in:
Jens
2026-07-21 14:00:00 +02:00
commit b8091e59bd
285 changed files with 27854 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import json
from pathlib import Path
import httpx
import pytest
from django.test import override_settings
from apps.jobs.models import AiAnalysisCache
from apps.jobs.services import ai as ai_service
from apps.jobs.services.ai import analyze_job_text
def _evaluation_cases() -> list[dict[str, object]]:
path = Path(__file__).resolve().parents[1] / "fixtures" / "ai" / "evaluation_set.json"
return json.loads(path.read_text(encoding="utf-8"))["cases"]
@pytest.mark.parametrize("case", _evaluation_cases(), ids=lambda case: case["id"])
def test_ai_evaluation_cases(monkeypatch, case):
if case["response_type"] == "disabled":
with override_settings(OLLAMA_ENABLED=False, OLLAMA_MODEL=""):
result = analyze_job_text(
title=case["title"],
description=case["description"],
content_hash=case["content_hash"],
)
assert result.status == ai_service.AiAnalysisCache.Status.DISABLED
assert result.error_category == "ollama_disabled"
assert result.cached is False
assert result.summary_nl == ""
return
if case["response_type"] == "timeout":
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
raise httpx.TimeoutException("timeout")
monkeypatch.setattr(httpx, "Client", FakeClient)
else:
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
return _fake_response(case["response"])
monkeypatch.setattr(httpx, "Client", FakeClient)
with override_settings(
OLLAMA_ENABLED=True,
OLLAMA_MODEL="local-model",
OLLAMA_BASE_URL="http://ollama:11434",
OLLAMA_TIMEOUT_SECONDS=2,
):
result = analyze_job_text(
title=case["title"],
description=case["description"],
content_hash=case["content_hash"],
)
assert result.status == case["expected_status"]
assert result.error_category == case["expected_error_category"]
assert result.schema_version == "1.0.0"
if result.status == ai_service.AiAnalysisCache.Status.OK:
assert result.summary_nl == case["expected_summary"]
assert round(result.features.get("support_ratio", 0), 3) == case["expected_support_ratio"]
assert result.features.get("evidence") == case["expected_evidence"]
if result.status != ai_service.AiAnalysisCache.Status.OK:
assert result.features == {}
def _fake_response(response_text: object):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"response": response_text}
return FakeResponse()
def test_ai_cache_reuses_result(monkeypatch):
calls = 0
class FakeClient:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
nonlocal calls
calls += 1
return _fake_response(
'{"summary_nl":"Gevalideerde IT-ops advertentie.","features":{"support_ratio":0.03,"consultancy_ratio":0.01,"travel_ratio":0.02,"seniority":"senior","evidence":["IT"]},"warnings":[]}'
)
monkeypatch.setattr(httpx, "Client", FakeClient)
with override_settings(
OLLAMA_ENABLED=True,
OLLAMA_MODEL="local-model",
OLLAMA_BASE_URL="http://ollama:11434",
):
first = analyze_job_text(
"Cloud Engineer",
"Cloud Engineer vacature. IT-ops taken incl. Azure en Linux.",
content_hash="cache-hit-case",
)
second = analyze_job_text(
"Cloud Engineer",
"Cloud Engineer vacature. IT-ops taken incl. Azure en Linux.",
content_hash="cache-hit-case",
)
assert first.status == ai_service.AiAnalysisCache.Status.OK
assert second.status == ai_service.AiAnalysisCache.Status.OK
assert first.cached is False
assert second.cached is True
assert calls == 1
assert AiAnalysisCache.objects.count() == 1
+123
View File
@@ -0,0 +1,123 @@
from pathlib import Path
from apps.sources.adapters.ats import (
GreenhouseAdapter,
LeverAdapter,
RecruiteeAdapter,
SmartRecruitersAdapter,
WorkableAdapter,
)
def _load_fixture(name: str) -> str:
return Path(name).read_text(encoding="utf-8")
def _assert_evidence_fields(job, *, expected_keys):
evidence_fields = {item.field_name for item in job.evidence}
assert expected_keys.issubset(evidence_fields)
def test_greenhouse_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = GreenhouseAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/greenhouse-listing.json"), url="https://boards.greenhouse.io/example")
assert result.parser_key == "ats-greenhouse"
assert len(result.jobs) == 2
assert {job.external_id for job in result.jobs} == {"gh-open-1", "gh-open-2"}
assert all("closed" not in result.warnings for _ in [0])
assert result.confidence >= 0.89
_assert_evidence_fields(result.jobs[0], expected_keys={"external_id", "url", "location_text", "date_posted", "employment_types"})
def test_greenhouse_adapter_detail_supports_changed_markup():
adapter = GreenhouseAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/greenhouse-detail.json"), url="https://boards.greenhouse.io/example/jobs/senior-software-engineer")
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Senior Software Engineer"
assert job.external_id == "gh-open-1"
assert job.url == "https://boards.greenhouse.io/example/jobs/senior-software-engineer"
assert job.description_html
_assert_evidence_fields(job, expected_keys={"external_id", "url", "location_text", "date_posted", "valid_through", "employment_types"})
def test_lever_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = LeverAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/lever-listing.json"), url="https://jobs.lever.co/example")
assert result.parser_key == "ats-lever"
assert len(result.jobs) == 2
assert {job.external_id for job in result.jobs} == {"lv-open-1", "lv-open-2"}
assert result.confidence >= 0.89
def test_lever_adapter_detail_supports_changed_markup():
adapter = LeverAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/lever-detail.json"), url="https://jobs.lever.co/example/platform-engineer")
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Platform Engineer"
assert job.raw["position"]["workplaceType"] == "remote"
assert "work_type" in job.raw
assert job.location_text == "Aalst"
def test_recruitee_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = RecruiteeAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/recruitee-listing.json"), url="https://acme.recruitee.com/o/" )
assert result.parser_key == "ats-recruitee"
assert len(result.jobs) == 2
assert {job.title for job in result.jobs} == {"System Engineer", "Network Engineer"}
def test_recruitee_adapter_detail_supports_changed_markup():
adapter = RecruiteeAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/recruitee-detail.json"), url="https://acme.recruitee.com/o/system-engineer")
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.external_id == "rc-open-1"
assert job.title == "System Engineer"
assert job.employment_types == ["permanent"]
def test_smartrecruiters_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = SmartRecruitersAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/smartrecruiters-listing.json"), url="https://jobs.smartrecruiters.com/example")
assert result.parser_key == "ats-smartrecruiters"
assert len(result.jobs) == 2
assert "sr-closed-1" not in {job.external_id for job in result.jobs}
def test_smartrecruiters_adapter_detail_supports_changed_markup():
adapter = SmartRecruitersAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/smartrecruiters-detail.json"), url="https://jobs.smartrecruiters.com/example/security-engineer")
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.title == "Security Engineer"
assert job.workplace_type in {"remote", "hybrid", ""}
def test_workable_adapter_listing_extracts_open_jobs_and_skips_closed():
adapter = WorkableAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/workable-listing.json"), url="https://apply.workable.com/example")
assert result.parser_key == "ats-workable"
assert len(result.jobs) == 2
assert result.jobs[0].title == "Data Engineer"
def test_workable_adapter_detail_supports_changed_markup():
adapter = WorkableAdapter()
result = adapter.extract(_load_fixture("fixtures/ats/workable-detail.json"), url="https://apply.workable.com/example/data-engineer/")
assert len(result.jobs) == 1
job = result.jobs[0]
assert job.external_id == "wb-open-1"
assert job.location_text == "Brussel"
+13
View File
@@ -0,0 +1,13 @@
from apps.sources.services.canonicalize import canonicalize_url, domain_matches
def test_canonicalize_url_removes_tracking_and_fragment():
value = canonicalize_url(
"HTTPS://Jobs.Example.org:443/jobs/../jobs/42/?utm_source=mail&b=2&a=1#apply"
)
assert value == "https://jobs.example.org/jobs/42/?a=1&b=2"
def test_domain_matches_subdomains_but_not_suffix_attack():
assert domain_matches("www.linkedin.com", "linkedin.com")
assert not domain_matches("linkedin.com.evil.example", "linkedin.com")
+107
View File
@@ -0,0 +1,107 @@
from pathlib import Path
import pytest
from apps.sources.models import Source
from apps.sources.services.discovery import (
SourceCandidate,
discover_career_links,
discover_from_email,
discover_from_feed,
discover_from_html,
discover_from_sitemap,
persist_discovery_candidates,
)
def test_discovery_finds_career_links_and_skips_denied_domains():
html = """
<a href="/werken-bij">Werken bij ons</a>
<a href="https://careers.partner.example/jobs">Jobs partner</a>
<a href="https://www.linkedin.com/jobs">Jobs op LinkedIn</a>
<a href="/about">Over ons</a>
"""
result = discover_career_links(html, base_url="https://example.org/")
assert [item.url for item in result] == [
"https://example.org/werken-bij",
"https://careers.partner.example/jobs",
]
assert result[0].confidence > result[1].confidence
def test_discovery_from_html_includes_jsonld_and_feed_links():
html = Path("fixtures/discovery/html_candidate_discovery.html").read_text(encoding="utf-8")
result = discover_from_html(html, base_url="https://example.org/")
candidate_urls = {candidate.url: candidate.discovered_from for candidate in result}
assert candidate_urls["https://example.org/werken-bij"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/data-engineer"] == "html-jsonld"
assert candidate_urls["https://jobs.example.org/vacatures/data-engineer/solliciteer"] == "html-jsonld"
assert candidate_urls["https://example.org/feed/jobs.xml"] == "html"
assert candidate_urls["https://jobs.example.org/vacatures/data-engineer"].startswith("html-jsonld")
assert any(
c.source_type == Source.Type.RSS and c.reason == "feed" for c in result
)
assert any(
c.source_type == Source.Type.EMPLOYER and c.url == "https://example.org/werken-bij"
for c in result
)
def test_discovery_from_feed_detects_internal_links_only():
content = Path("fixtures/discovery/feed_jobs.xml").read_text(encoding="utf-8")
result = discover_from_feed(content, base_url="https://jobs.example.org/feed/jobs")
assert len(result) == 1
assert result[0].url == "https://jobs.example.org/vacatures/cloud-architect"
assert result[0].source_type == Source.Type.RSS
def test_discovery_from_sitemap_prefers_candidates_and_filters_private_or_denied():
index_content = Path("fixtures/discovery/sitemap_index.xml").read_text(encoding="utf-8")
urlset_content = Path("fixtures/discovery/sitemap_urlset.xml").read_text(encoding="utf-8")
index_candidates = discover_from_sitemap(index_content, base_url="https://jobs.example.org/")
urlset_candidates = discover_from_sitemap(urlset_content, base_url="https://jobs.example.org/")
assert len(index_candidates) == 1
assert index_candidates[0].url == "https://jobs.example.org/sitemap-jobs.xml"
assert len(urlset_candidates) >= 2
assert any(item.url == "https://jobs.example.org/vacatures/data-engineer" for item in urlset_candidates)
assert all("linkedin.com" not in item.url for item in index_candidates + urlset_candidates)
def test_discovery_from_email_extracts_employer_domain_root():
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
result = discover_from_email(raw)
assert [item.url for item in result] == ["https://jobs.example.org"]
assert result[0].source_type == Source.Type.EMPLOYER
@pytest.mark.django_db
def test_discovery_persistence_is_idempotent():
candidates = [
SourceCandidate(
url="https://jobs.example.org/vacatures/data-engineer",
domain="jobs.example.org",
source_type=Source.Type.EMPLOYER,
label="Testbron",
confidence=0.95,
reason="test",
discovered_from="unit-test",
)
]
created, updated, skipped = persist_discovery_candidates(candidates)
assert (created, updated, skipped) == (1, 0, 0)
created, updated, skipped = persist_discovery_candidates(candidates)
assert (created, updated, skipped) == (0, 0, 1)
source = Source.objects.get(domain="jobs.example.org", source_type=Source.Type.EMPLOYER)
assert source.status == Source.Status.CANDIDATE
assert source.policy == Source.Policy.REVIEW
assert source.name == "Testbron"
assert len(source.discovery_evidence) == 1
+12
View File
@@ -0,0 +1,12 @@
from pathlib import Path
from apps.sources.adapters.email_alert import EmailAlertAdapter
def test_email_adapter_extracts_job_links_and_skips_unsubscribe():
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
result = EmailAlertAdapter().extract_message(raw)
urls = {job.url for job in result.jobs}
assert "https://jobs.example.org/vacatures/infrastructure-engineer" in urls
assert "https://www.linkedin.com/jobs/view/123456" in urls
assert all("unsubscribe" not in url for url in urls)
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from apps.jobs.models import Employer, JobPosting
from apps.jobs.services.employer_resolution import resolve_direct_employer_match
from apps.jobs.services.normalization import CanonicalJobDraft
from apps.sources.models import Source
def _draft_for_test(job: JobPosting, *, **overrides) -> CanonicalJobDraft:
values = {
"source_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"canonical_url": "https://jobs.smartrecruiters.com/example/security-engineer",
"external_id": "sr-security-123",
"title": job.original_title,
"normalized_title": job.normalized_title,
"job_family": job.job_family,
"employer_name": job.employer_name,
"employer_domain": "jobs.example.org",
"location_text": job.raw_location,
"region": job.region,
"municipality": job.municipality,
"postal_code": job.postal_code,
"country": job.country,
"workplace_type": job.workplace_type,
"employment_types": job.employment_types,
"language": "nl",
"description_html": "<p>Test</p>",
"description_text": "test beschrijving",
"date_posted": datetime(2026, 1, 10, tzinfo=UTC),
"valid_through": datetime(2026, 6, 1, tzinfo=UTC),
"compensation": {},
"skills_required": [],
"skills_preferred": [],
"content_hash": job.content_hash,
"canonical_key": "x" * 64,
"evidence": [],
"raw": {},
}
values.update(overrides)
return CanonicalJobDraft(**values)
@pytest.mark.django_db
def test_resolver_merges_recruiter_alias_to_direct_source():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="a" * 64,
job_family="security",
content_hash="a" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
# Use an existing direct employer job as the resolver target.
target_job = JobPosting.objects.first()
decision = resolve_direct_employer_match(
_draft_for_test(target_job),
source=source,
)
assert decision.job == target_job
assert decision.reason == "resolved_direct_match"
assert decision.canonical_url == "https://jobs.example.org/vacatures/security-engineer"
@pytest.mark.django_db
def test_resolver_flags_conflicting_location_for_review():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="b" * 64,
job_family="security",
content_hash="b" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="SmartRecruiters",
source_type=Source.Type.ATS,
base_url="https://jobs.smartrecruiters.com/example",
domain="jobs.smartrecruiters.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
location_text="Gent",
municipality="Gent",
region="Oost-Vlaanderen",
),
source=source,
)
assert decision.job is None
assert decision.reason == "review_direct_conflict"
@pytest.mark.django_db
def test_resolver_keeps_candidate_unmerged_when_similarity_is_conservative():
employer = Employer.objects.create(
name="Example IT",
normalized_name="example it",
domain="jobs.example.org",
)
JobPosting.objects.create(
employer=employer,
original_title="Security Engineer",
normalized_title="security engineer",
canonical_url="https://jobs.example.org/vacatures/security-engineer",
canonical_key="c" * 64,
job_family="security",
content_hash="c" * 64,
description_text="Security engineer voor beveiliging.",
raw_location="Antwerpen",
region="Antwerpen",
municipality="Antwerpen",
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time"],
direct_employer=True,
)
source = Source.objects.create(
name="StaffingBoard",
source_type=Source.Type.ATS,
base_url="https://jobs.staffingboard.com/example",
domain="jobs.staffingboard.com",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
decision = resolve_direct_employer_match(
_draft_for_test(
JobPosting.objects.first(),
title="Data Analyst",
normalized_title="data analyst",
location_text="Brussel",
employer_name="Another Employer",
employer_domain="jobs.staffingboard.com",
),
source=source,
)
assert decision.job is None
assert decision.reason == "no_direct_resolution"
+45
View File
@@ -0,0 +1,45 @@
from apps.jobs.services.distance import CommuteEstimate, estimate_commute, haversine_km
from apps.jobs.services.features import extract_deterministic_features, term_ratio
def test_feature_extraction_and_distance_are_deterministic():
text = (
"Voor een gemeente zoeken we een consultant. Rijbewijs B en 5 jaar ervaring vereist. "
"De rol bevat helpdesk en telefonische support bij klanten."
)
features = extract_deterministic_features("Support Engineer", text)
assert features["support_ratio"] > 0
assert features["consultancy_ratio"] > 0
assert features["travel_ratio"] > 0
assert features["public_sector_signal"] > 0
assert features["experience_years_max"] == 5
assert term_ratio("", ["x"]) == 0
assert 60 < haversine_km(50.9307, 5.3325, 50.8503, 4.3517) < 80
def test_conservative_commute_estimate_is_marked_as_estimate():
result = estimate_commute(20.0)
assert isinstance(result, CommuteEstimate)
assert result.is_estimate
assert result.minutes > 0
assert result.source == "road"
def test_custom_commute_estimator_is_accepted():
class CustomEstimator:
name = "custom"
version = "test"
def estimate(self, distance_km: float) -> CommuteEstimate:
return CommuteEstimate(
minutes=int(distance_km * 2),
km=distance_km,
source=self.name,
source_version=self.version,
confidence=0.7,
is_estimate=False,
)
result = estimate_commute(12.0, estimator=CustomEstimator())
assert result.minutes == 24
assert result.source_version == "test"
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from apps.jobs.models import Feedback
from apps.jobs.services.feedback import record_feedback
def test_learning_signals_wait_for_min_samples(profile, job, user):
first = record_feedback(user=user, job=job, action=Feedback.Action.INTERESTING)
second = record_feedback(user=user, job=job, action=Feedback.Action.INTERESTING)
first_metadata = first.metadata.get("learning", {})
second_metadata = second.metadata.get("learning", {})
assert first_metadata.get("status") == "queued"
assert first_metadata.get("reason_code") == "positive_feedback"
assert second_metadata.get("status") == "applied"
profile.refresh_from_db()
assert profile.weights["content"] == 26.0
def test_learning_skip_for_implicit_hide(profile, job, user):
feedback = record_feedback(user=user, job=job, action=Feedback.Action.HIDE)
metadata = feedback.metadata.get("learning", {})
assert metadata["status"] == "queued"
assert metadata["reason_code"] == "implicit_hide_no_reason"
profile.refresh_from_db()
assert profile.weights["content"] == 25.0
def test_learning_marks_explicit_title_hide_as_nonlearning(profile, job, user):
feedback = record_feedback(user=user, job=job, action=Feedback.Action.HIDE, reason="Titel past niet bij mij")
metadata = feedback.metadata.get("learning", {})
assert metadata["status"] == "queued"
assert metadata["reason_code"] == "non_learning_title"
+148
View File
@@ -0,0 +1,148 @@
import httpx
import pytest
from django.test import override_settings
from apps.sources.services.fetcher import (
ContentRejectedError,
FetchError,
FetchTimeoutError,
RateLimitedError,
PolicyBlockedError,
fetch_url,
)
from apps.sources.services.url_security import ValidatedUrl
PUBLIC_DNS = [(None, None, None, None, ("93.184.216.34", 443))]
def _patch_dns(monkeypatch):
monkeypatch.setattr(
"apps.sources.services.url_security.socket.getaddrinfo",
lambda *args, **kwargs: PUBLIC_DNS,
)
# validate_public_url captured the default resolver at definition time, so patch call site too.
original = __import__(
"apps.sources.services.url_security", fromlist=["validate_public_url"]
).validate_public_url
def validate(url, **kwargs):
return original(url, resolver=lambda *a, **k: PUBLIC_DNS, **kwargs)
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def test_fetcher_success_redirect_304_and_hash(monkeypatch):
_patch_dns(monkeypatch)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/start":
return httpx.Response(302, headers={"location": "/job"})
return httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"Vacature",
)
client = httpx.Client(transport=httpx.MockTransport(handler))
document = fetch_url("https://example.org/start", client=client)
assert document.final_url == "https://example.org/job"
assert document.text == "Vacature"
assert len(document.sha256) == 64
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(304, headers={"etag": "x"}))
)
unchanged = fetch_url("https://example.org/job", client=client)
assert unchanged.status_code == 304
assert unchanged.content == b""
client.close()
def test_fetcher_respects_rate_limit_and_retry_after(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
429, headers={"retry-after": "45"}, content=b"too many requests"
)
)
)
with pytest.raises(RateLimitedError) as exc:
fetch_url("https://example.org/too-fast", client=client)
assert exc.value.retry_after_seconds == 45
client.close()
def test_fetcher_rejects_dns_rebinding(monkeypatch):
_patch_dns(monkeypatch)
def validate(url: str, **kwargs):
if "/final" in url:
return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=("198.51.100.12",))
return ValidatedUrl(url=url, hostname="example.org", port=443, addresses=("93.184.216.34",))
monkeypatch.setattr("apps.sources.services.fetcher.validate_public_url", validate)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/start":
return httpx.Response(302, headers={"location": "/final"})
return httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"Vacature",
)
client = httpx.Client(transport=httpx.MockTransport(handler))
with pytest.raises(FetchError, match="DNS-rebindcontrole"):
fetch_url("https://example.org/start", client=client)
client.close()
def test_fetcher_rejects_policy_content_size_and_http(monkeypatch):
_patch_dns(monkeypatch)
with pytest.raises(PolicyBlockedError):
fetch_url("https://linkedin.com/jobs", client=httpx.Client())
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(200, headers={"content-type": "image/png"}, content=b"x")
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/image", client=client)
client.close()
with override_settings(FETCHER_MAX_BYTES=2):
client = httpx.Client(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200, headers={"content-type": "text/plain"}, content=b"long"
)
)
)
with pytest.raises(ContentRejectedError):
fetch_url("https://example.org/large", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(500, content=b"error"))
)
with pytest.raises(FetchError, match="HTTP 500"):
fetch_url("https://example.org/error", client=client)
client.close()
client = httpx.Client(
transport=httpx.MockTransport(lambda request: (_ for _ in ()).throw(httpx.TimeoutException("timeout")))
)
with pytest.raises(FetchTimeoutError):
fetch_url("https://example.org/slow", client=client)
client.close()
def test_fetcher_rejects_redirect_without_location(monkeypatch):
_patch_dns(monkeypatch)
client = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(302)))
with pytest.raises(FetchError, match="Location"):
fetch_url("https://example.org/start", client=client)
client.close()
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from apps.sources.adapters.generic_html import GenericHtmlAdapter
def test_generic_html_fallback_extracts_core_fields():
html = Path("fixtures/pages/sample_generic_job.html").read_text(encoding="utf-8")
result = GenericHtmlAdapter().extract(html, url="https://careers.example.net/jobs/1")
assert result.jobs[0].title == "Workplace Engineer"
assert result.jobs[0].employer_name == "Example Health"
assert "Microsoft Intune" in result.jobs[0].description_text
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
import pytest
from django.core.management import CommandError
from apps.jobs.models import GeocodeLocationLookup
from apps.jobs.services.geocoding import (
CsvGeocodeProvider,
GeoPoint,
LocationMatch,
LocationMatchResult,
import_csv_geodata,
parse_belgian_location_query,
resolve_location,
validate_csv_geodata,
)
@pytest.mark.django_db
def test_parse_belgian_location_query():
postal, municipality = parse_belgian_location_query("Gent, 9000")
assert postal == "9000"
assert municipality == "gent"
postal, municipality = parse_belgian_location_query("1050 Ixelles")
assert postal == "1050"
assert municipality == "ixelles"
postal, municipality = parse_belgian_location_query("Brussel")
assert postal is None
assert municipality == "brussel"
@pytest.mark.django_db
def test_geocode_provider_resolves_postal_and_municipality_exactly():
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="postal",
query_value="9000",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.0500"),
longitude=Decimal("3.7300"),
confidence=Decimal("0.90"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="gent",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.0500"),
longitude=Decimal("3.7300"),
confidence=Decimal("0.90"),
)
provider = CsvGeocodeProvider(source_name="fixture", source_version="2026-01-01")
result = resolve_location("9000 Gent", provider)
assert result.ambiguous is False
assert result.location is not None
assert result.location.postal_code == "9000"
assert result.location.municipality == "Gent"
assert result.location.point == GeoPoint(latitude=51.05, longitude=3.73)
@pytest.mark.django_db
def test_geocode_provider_marks_ambiguous_as_unknown():
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="brussel",
postal_code="1000",
municipality="Brussel",
region="Brussels Hoofdstedelijk Gewest",
latitude=Decimal("50.8500"),
longitude=Decimal("4.3500"),
confidence=Decimal("0.85"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="2026-01-01",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="brussel",
postal_code="1000-200",
municipality="Brussel",
region="Brussels Hoofdstedelijk Gewest",
latitude=Decimal("50.8400"),
longitude=Decimal("4.3400"),
confidence=Decimal("0.85"),
)
provider = CsvGeocodeProvider(source_name="fixture", source_version="2026-01-01")
result = resolve_location("Brussel", provider)
assert result.ambiguous is True
assert result.location is None
@pytest.mark.django_db
def test_validate_csv_geodata_flags_duplicates_and_invalid_coordinates(tmp_path: Path):
source = tmp_path / "geodata.csv"
source.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,51.05,3.73\n"
"9000,Gent,Vlaams-Brabant,51.05,3.74\n"
)
with pytest.raises(CommandError, match="dubbel record"):
validate_csv_geodata(source)
source.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,99,3.73\n"
)
with pytest.raises(CommandError, match="latitude buiten bereik"):
validate_csv_geodata(source)
@pytest.mark.django_db
def test_import_csv_geodata_is_atomic_without_replace(tmp_path: Path):
source = tmp_path / "geodata.csv"
source.write_text(
"postal_code,municipality,region,latitude,longitude\n"
"9000,Gent,Vlaams-Brabant,51.05,3.73\n"
"1040,Brussel,Brussels Hoofdstedelijk Gewest,50.85,4.35\n"
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="manual",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="municipality",
query_value="gent",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.05"),
longitude=Decimal("3.73"),
confidence=Decimal("1.00"),
)
GeocodeLocationLookup.objects.create(
source_name="fixture",
source_version="manual",
source_license_name="",
source_license_url="",
source_metadata={},
query_kind="postal",
query_value="9000",
postal_code="9000",
municipality="Gent",
region="Vlaams-Brabant",
latitude=Decimal("51.05"),
longitude=Decimal("3.73"),
confidence=Decimal("1.00"),
)
with pytest.raises(CommandError, match="overschrijven zonder --replace"):
import_csv_geodata(source, source_name="fixture", source_version="manual")
assert GeocodeLocationLookup.objects.count() == 2
def test_resolve_location_keeps_ambiguous_unknown() -> None:
class StubProvider:
def resolve(self, query: str):
return [
LocationMatch(
postal_code="1000",
municipality="Brussel",
region="Brussels",
point=None,
confidence=0.85,
source="csv",
source_version="manual",
metadata={},
),
LocationMatch(
postal_code="1001",
municipality="Brussel",
region="Brussels",
point=None,
confidence=0.85,
source="csv",
source_version="manual",
metadata={},
),
]
result = resolve_location("Brussel", StubProvider())
assert result.ambiguous is True
assert isinstance(result, LocationMatchResult)
assert result.location is None
+15
View File
@@ -0,0 +1,15 @@
import json
from pathlib import Path
from apps.sources.adapters.jsonld import JsonLdJobPostingAdapter
def test_extracts_sample_jobposting():
html = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
expected = json.loads(Path("fixtures/golden/sample_jsonld_job.json").read_text())
result = JsonLdJobPostingAdapter().extract(html, url="https://jobs.example.org/source")
assert result.confidence >= 0.9
assert len(result.jobs) == 1
job = result.jobs[0]
for key, value in expected.items():
assert getattr(job, key) == value
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import pytest
from apps.sources.models import Source, SourceRun
from apps.sources.services.fetcher import FetchedDocument
from apps.sources.services.manual_import import ManualImportError, import_manual_source
def test_manual_import_url_uses_fetch_and_succeeds(monkeypatch, user):
def fake_fetch(*_args, **_kwargs):
return FetchedDocument(
requested_url="https://jobs.example.org/jobs",
final_url="https://jobs.example.org/jobs",
status_code=200,
headers={
"content-type": "text/html; charset=utf-8",
"etag": "abc",
"last-modified": "Mon, 01 Jan 2000 00:00:00 GMT",
},
content=b"<html><body>vacature</body></html>",
)
def fake_process(document):
return {
"extracted": 2,
"created": 1,
"updated": 1,
"duplicates": 0,
"parser": "manual-test",
"warnings": [],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fake_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
summary = import_manual_source(
actor=user, source_url="https://jobs.example.org/jobs"
)
source = Source.objects.get(pk=summary.source_id)
assert summary.mode == "url"
assert summary.extracted_count == 2
assert summary.created_count == 1
assert source.source_type == Source.Type.MANUAL
assert source.domain == "jobs.example.org"
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SUCCESS).exists()
def test_manual_import_paste_text_sanitizes_and_skips_fetch(monkeypatch, user):
def fail_fetch(*_args, **_kwargs):
raise AssertionError("Fetcher should not be called for pasted text.")
def fake_process(document):
return {
"extracted": 0,
"created": 0,
"updated": 0,
"duplicates": 0,
"parser": "manual-test",
"warnings": ["noop"],
}
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fail_fetch)
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
summary = import_manual_source(
actor=user, pasted_text="<script>alert('x')</script>\nVacaturetekst"
)
source = Source.objects.get(pk=summary.source_id)
assert summary.mode == "paste"
assert source.source_type == Source.Type.MANUAL
assert source.base_url.startswith("https://manual-")
def test_manual_import_rejects_denied_domain(user):
with pytest.raises(ManualImportError):
import_manual_source(actor=user, source_url="https://linkedin.com/jobs/123")
def test_manual_import_rejects_paste_payload_when_too_large(user, settings):
settings.MANUAL_IMPORT_PASTE_MAX_BYTES = 12
huge_text = "a" * 20
with pytest.raises(ManualImportError):
import_manual_source(actor=user, pasted_text=huge_text)
+15
View File
@@ -0,0 +1,15 @@
from apps.jobs.services.normalization import (
infer_language,
normalize_employment_types,
normalize_title,
)
def test_title_and_employment_normalization():
assert normalize_title("Senior System Engineer (m/v/x) - Fulltime") == "senior system engineer"
assert normalize_employment_types(["FULL_TIME", "vast"]) == ["full_time", "permanent"]
def test_language_detection_is_conservative():
assert infer_language("Je beheert de infrastructuur en werkt met een team") == "nl"
assert infer_language("You manage the infrastructure and work with a team") == "en"
+71
View File
@@ -0,0 +1,71 @@
from decimal import Decimal
import pytest
from django.core.exceptions import ValidationError
from apps.profiles.models import ProfileRevision, SearchProfile
from apps.profiles.services import apply_feedback_delta, save_profile_revision
@pytest.mark.django_db
def test_profile_validation_snapshot_activation_and_revisions(user, profile):
profile.full_clean()
snapshot = profile.snapshot()
assert snapshot["home_municipality"] == "Hasselt"
assert snapshot["home_latitude"] == pytest.approx(50.9307)
other = SearchProfile.objects.create(user=user, name="Tweede", is_active=False)
other.activate()
profile.refresh_from_db()
other.refresh_from_db()
assert other.is_active is True
assert profile.is_active is False
revision = save_profile_revision(other, reason="test")
assert revision.version == 1
assert ProfileRevision.objects.filter(profile=other).count() == 1
second = save_profile_revision(other, reason="test-2")
assert second.version == 2
other.refresh_from_db()
assert other.version == 2
@pytest.mark.django_db
def test_feedback_delta_is_bounded_and_can_be_disabled(profile):
apply_feedback_delta(profile, "content", 100)
profile.refresh_from_db()
assert profile.weights["content"] == 40.0
apply_feedback_delta(profile, "content", -100)
profile.refresh_from_db()
assert profile.weights["content"] == 0.0
profile.learning_enabled = False
profile.weights["skills"] = 20.0
profile.save()
apply_feedback_delta(profile, "skills", 5)
profile.refresh_from_db()
assert profile.weights["skills"] == 20.0
@pytest.mark.parametrize(
("field", "value"),
[
("weights", {"content": "not-a-number"}),
("weights", {"content": -1}),
("home_latitude", Decimal("91")),
("home_longitude", Decimal("181")),
],
)
@pytest.mark.django_db
def test_profile_rejects_invalid_values(profile, field, value):
setattr(profile, field, value)
with pytest.raises(ValidationError):
profile.full_clean()
@pytest.mark.django_db
def test_profile_rejects_inverted_thresholds(profile):
profile.recommendation_threshold = 91
profile.top_match_threshold = 90
with pytest.raises(ValidationError):
profile.full_clean()
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from django.core.cache import cache
from django.test import RequestFactory
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
@pytest.fixture(autouse=True)
def _clear_rate_limit_cache():
cache.clear()
yield
cache.clear()
def _post_request(payload: dict[str, str], *, username: str | None = None):
factory = RequestFactory()
request = factory.post("/login/", data=payload)
request.META["REMOTE_ADDR"] = "203.0.113.10"
if username is None:
request.user = SimpleNamespace(is_authenticated=False, is_anonymous=True)
else:
request.user = SimpleNamespace(is_authenticated=True, pk=42)
payload["username"] = username
request.user.username = username
return request
def test_rate_limit_blocks_after_max_attempts_and_reports_block_state():
request = _post_request({"username": "alice"}, username="alice")
state = is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 0
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert not state.is_blocked
assert state.attempts == 1
state = register_rate_limit_failure(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
)
assert state.is_blocked
assert state.remaining_seconds > 0
assert is_rate_limited(
request,
namespace="manual_import",
max_attempts=2,
window_seconds=300,
block_seconds=120,
).is_blocked
def test_rate_limit_clear_resets_state_for_authenticated_identity():
request = _post_request({"username": "alice"}, username="alice")
state = register_rate_limit_failure(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert state.is_blocked
clear_rate_limit(request, namespace="login")
state = is_rate_limited(
request,
namespace="login",
max_attempts=1,
window_seconds=300,
block_seconds=10,
)
assert not state.is_blocked
assert state.attempts == 0
+83
View File
@@ -0,0 +1,83 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.sources.models import Source, SourceLease
from apps.sources.services.scheduling import (
acquire_source_lease,
calculate_failure_backoff_seconds,
release_source_lease,
)
@pytest.mark.django_db
def test_source_lease_blocks_concurrent_workers_and_recovers_when_expired(source):
now = timezone.now()
lease_a = acquire_source_lease(source_id=source.pk, worker_token="worker-a", now=now)
assert lease_a is not None
assert acquire_source_lease(source_id=source.pk, worker_token="worker-b", now=now) is None
assert release_source_lease(source_id=source.pk, worker_token="worker-a", now=now)
lease_b = acquire_source_lease(source_id=source.pk, worker_token="worker-b", now=timezone.now())
assert lease_b is not None
@pytest.mark.django_db
def test_source_lease_reuses_expired_lease(db, source):
now = timezone.now()
SourceLease.objects.create(
source=source,
token="expired-worker",
worker_id="expired-worker",
expires_at=now - timedelta(minutes=1),
)
lease = acquire_source_lease(source_id=source.pk, worker_token="recovery-worker", now=now)
assert lease is not None
assert lease.worker_id == "recovery-worker"
@pytest.mark.django_db
def test_source_lease_allows_origin_concurrency(db):
shared_domain = "shared.example.org"
source_a = Source.objects.create(
name="Source A",
source_type=Source.Type.EMPLOYER,
base_url="https://shared.example.org/jobs/",
domain=shared_domain,
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
max_concurrency=2,
)
source_b = Source.objects.create(
name="Source B",
source_type=Source.Type.EMPLOYER,
base_url="https://shared.example.org/careers/",
domain=shared_domain,
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
max_concurrency=2,
)
now = timezone.now()
lease_a = acquire_source_lease(source_id=source_a.pk, worker_token="worker-a", now=now)
lease_b = acquire_source_lease(source_id=source_b.pk, worker_token="worker-b", now=now)
assert lease_a is not None
assert lease_b is not None
@pytest.mark.django_db
def test_failure_backoff_uses_retry_after_and_bounds():
source = Source(
name="Temp",
source_type=Source.Type.EMPLOYER,
base_url="https://retry.example.org/",
domain="retry.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
source.save()
backoff = calculate_failure_backoff_seconds(
source, failure_count=2, retry_after_seconds=120
)
assert backoff >= 120
assert backoff <= 3600 + 45
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from decimal import Decimal
import pytest
from apps.jobs.models import AiAnalysisCache
from apps.jobs.services.ai import AiAnalysis
from apps.jobs.models import Employer, JobPosting, ScoreRun
from apps.jobs.services import scoring
from apps.jobs.services.scoring import calculate_score
@pytest.fixture
def matching_job(db):
employer = Employer.objects.create(
name="Example Public IT",
normalized_name="example public it",
domain="jobs.example.org",
is_direct_employer=True,
)
return JobPosting.objects.create(
employer=employer,
original_title="Infrastructure Engineer",
normalized_title="infrastructure engineer",
canonical_url="https://jobs.example.org/jobs/1",
canonical_key="a" * 64,
content_hash="b" * 64,
description_text=(
"Beheer Microsoft 365 en VMware. Hybride werk en beperkte tweedelijnssupport."
),
raw_location="Hasselt, Limburg",
region="Limburg",
municipality="Hasselt",
postal_code="3500",
latitude=Decimal("50.930700"),
longitude=Decimal("5.332500"),
workplace_type=JobPosting.Workplace.HYBRID,
employment_types=["full_time", "permanent"],
skills_required=["Microsoft 365", "VMware"],
analysis_features={"support_ratio": 0.1, "public_sector_signal": 0.8},
direct_employer=True,
recruiter=False,
extraction_confidence=Decimal("0.95"),
status=JobPosting.Status.ACTIVE,
)
def test_matching_job_gets_recommendation(matching_job, profile):
result = calculate_score(matching_job, profile)
assert result.score >= profile.recommendation_threshold
assert result.recommendation in {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
}
assert not result.hard_exclusions
def test_hard_title_exclusion_wins(matching_job, profile):
matching_job.original_title = "IT Sales Infrastructure Engineer"
matching_job.save(update_fields=["original_title"])
result = calculate_score(matching_job, profile)
assert result.recommendation == ScoreRun.Recommendation.HIDDEN
assert any("sales" in reason.lower() for reason in result.hard_exclusions)
def test_distance_boundary_inclusief(matching_job, profile, monkeypatch):
profile.max_distance_km = 50
profile.save(update_fields=["max_distance_km"])
monkeypatch.setattr(scoring, "haversine_km", lambda *_args, **_kwargs: 50.0)
matching_job.refresh_from_db()
matching_job.save(update_fields=["latitude", "longitude"])
result = calculate_score(matching_job, profile)
assert result.evidence["distance_km"] == 50.0
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
assert not any("boven maximum" in issue for issue in result.hard_exclusions)
def test_remote_job_heeft_geen_afstandsexclusie(matching_job, profile):
matching_job.workplace_type = JobPosting.Workplace.REMOTE
matching_job.postal_code = None
matching_job.municipality = None
matching_job.raw_location = None
matching_job.save(update_fields=["workplace_type", "postal_code", "municipality", "raw_location"])
matching_job.latitude = None
matching_job.longitude = None
result = calculate_score(matching_job, profile)
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
assert all("boven maximum" not in issue for issue in result.hard_exclusions)
def test_onbekende_locatie_leidt_niet_tot_automatische_uitsluiting(matching_job, profile):
matching_job.postal_code = None
matching_job.municipality = None
matching_job.raw_location = "onbekend"
matching_job.latitude = None
matching_job.longitude = None
matching_job.save(update_fields=["postal_code", "municipality", "raw_location", "latitude", "longitude"])
result = calculate_score(matching_job, profile)
assert result.evidence["distance_km"] is None
assert result.recommendation != ScoreRun.Recommendation.HIDDEN
def test_score_is_tijdzoneonafhankelijk(profile, user, matching_job):
utc_profile = profile
utc_profile.timezone = "UTC"
utc_profile.save(update_fields=["timezone"])
result_utc = calculate_score(matching_job, utc_profile)
ny_profile = profile.__class__.objects.create(
user=user,
name="UTC vergelijken",
is_active=False,
home_municipality=utc_profile.home_municipality,
home_latitude=utc_profile.home_latitude,
home_longitude=utc_profile.home_longitude,
max_distance_km=utc_profile.max_distance_km,
desired_titles=utc_profile.desired_titles,
excluded_titles=utc_profile.excluded_titles,
desired_skills=utc_profile.desired_skills,
excluded_skills=utc_profile.excluded_skills,
allowed_employment_types=utc_profile.allowed_employment_types,
preferred_workplace=utc_profile.preferred_workplace,
preferred_regions=utc_profile.preferred_regions,
excluded_regions=utc_profile.excluded_regions,
recommendation_threshold=utc_profile.recommendation_threshold,
top_match_threshold=utc_profile.top_match_threshold,
digest_time=utc_profile.digest_time,
quiet_hours_start=utc_profile.quiet_hours_start,
quiet_hours_end=utc_profile.quiet_hours_end,
learning_enabled=utc_profile.learning_enabled,
weights=utc_profile.weights,
timezone="America/New_York",
)
result_ny = calculate_score(matching_job, ny_profile)
assert result_ny.score == result_utc.score
def test_ai_score_influence_is_opt_in_and_bounded(matching_job, profile, monkeypatch):
profile.ai_scoring_enabled = True
profile.weights = {**profile.weights, "ai": 999}
profile.save(update_fields=["ai_scoring_enabled", "weights"])
monkeypatch.setattr(
scoring,
"analyze_job_text",
lambda *args, **kwargs: AiAnalysis(
features={
"support_ratio": 0.1,
"consultancy_ratio": 0.0,
"travel_ratio": 0.0,
"seniority": "senior",
"evidence": ["microsoft 365"],
},
summary_nl="Sterke technische rol.",
warnings=[],
model="local-model",
status=AiAnalysisCache.Status.OK,
error_category="",
cached=False,
),
)
result = calculate_score(matching_job, profile)
assert result.recommendation in {ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE}
assert result.model_version == "local-model"
assert result.evidence["ai"]["status"] == AiAnalysisCache.Status.OK
assert result.evidence["ai"]["weight_applied"] == 20.0
assert "ai" in result.evidence
def test_ai_failure_keeps_deterministic_scoring(profile, matching_job, monkeypatch):
profile.ai_scoring_enabled = True
profile.weights = {**profile.weights, "ai": 10}
profile.save(update_fields=["ai_scoring_enabled", "weights"])
monkeypatch.setattr(
scoring,
"analyze_job_text",
lambda *args, **kwargs: AiAnalysis(
features={},
summary_nl="",
warnings=["service-timeout"],
model="local-model",
status=AiAnalysisCache.Status.TIMEOUT,
error_category="timeout",
cached=False,
),
)
result = calculate_score(matching_job, profile)
assert result.evidence["ai"]["status"] == AiAnalysisCache.Status.TIMEOUT
assert "AI-analyse" in "".join(result.concerns)
assert "components" in result.__dict__
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import pytest
from django.core.exceptions import ImproperlyConfigured
from config.settings import _validate_production_security
def test_validate_production_security_skips_when_debug_mode_is_enabled():
_validate_production_security(
debug=True,
secret_key="dev-only-change-me",
allowed_hosts=[],
csrf_trusted_origins=[],
session_cookie_secure=False,
csrf_cookie_secure=False,
secure_ssl_redirect=False,
)
@pytest.mark.parametrize(
"secret_key, allowed_hosts, csrf_trusted_origins, session_cookie_secure, csrf_cookie_secure, secure_ssl_redirect",
[
("", ["example.org"], ["https://example.org"], True, True, True),
("change-me", ["example.org"], ["https://example.org"], True, True, True),
("too-short", ["example.org"], ["https://example.org"], True, True, True),
("a" * 64, [], ["https://example.org"], True, True, True),
("a" * 64, ["example.org"], [], True, True, True),
("a" * 64, ["example.org"], ["http://example.org"], True, True, True),
("a" * 64, ["example.org"], ["https://example.org"], False, True, True),
("a" * 64, ["example.org"], ["https://example.org"], True, False, True),
("a" * 64, ["example.org"], ["https://example.org"], True, True, False),
],
)
def test_validate_production_security_requires_secure_settings_for_prod(
secret_key,
allowed_hosts,
csrf_trusted_origins,
session_cookie_secure,
csrf_cookie_secure,
secure_ssl_redirect,
):
with pytest.raises(ImproperlyConfigured):
_validate_production_security(
debug=False,
secret_key=secret_key,
allowed_hosts=allowed_hosts,
csrf_trusted_origins=csrf_trusted_origins,
session_cookie_secure=session_cookie_secure,
csrf_cookie_secure=csrf_cookie_secure,
secure_ssl_redirect=secure_ssl_redirect,
)
+42
View File
@@ -0,0 +1,42 @@
from datetime import timedelta
import pytest
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.sources.models import Source, SourceRun
@pytest.mark.django_db
def test_source_validation_and_scheduling(source):
now = timezone.now()
source.schedule_after_success(now=now)
source.refresh_from_db()
assert source.failure_count == 0
assert source.next_run_at == now + timedelta(minutes=source.crawl_interval_minutes)
source.schedule_after_failure(now=now, backoff_minutes=30)
source.refresh_from_db()
assert source.failure_count == 1
assert source.next_run_at == now + timedelta(minutes=30)
source.base_url = "https://other.example.org/jobs"
with pytest.raises(ValidationError):
source.full_clean()
source.base_url = "https://jobs.example.org/jobs"
source.policy = Source.Policy.DENY
source.status = Source.Status.ACTIVE
with pytest.raises(ValidationError):
source.full_clean()
@pytest.mark.django_db
def test_source_run_finish_updates_metrics(source):
run = SourceRun.objects.create(source=source)
run.finish(SourceRun.Status.SUCCESS, http_status=200, extracted_count=3)
run.refresh_from_db()
assert run.status == SourceRun.Status.SUCCESS
assert run.finished_at is not None
assert run.http_status == 200
assert run.extracted_count == 3