This commit is contained in:
+10
-3
@@ -9,10 +9,12 @@ 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[1] / "fixtures" / "ai" / "evaluation_set.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))["cases"]
|
||||
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"])
|
||||
@@ -31,6 +33,7 @@ def test_ai_evaluation_cases(monkeypatch, case):
|
||||
return
|
||||
|
||||
if case["response_type"] == "timeout":
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -46,6 +49,7 @@ def test_ai_evaluation_cases(monkeypatch, case):
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", FakeClient)
|
||||
else:
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -112,7 +116,10 @@ def test_ai_cache_reuses_result(monkeypatch):
|
||||
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":[]}'
|
||||
'{"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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pathlib import Path
|
||||
from pathlib import Path
|
||||
|
||||
from apps.sources.adapters.ats import (
|
||||
GreenhouseAdapter,
|
||||
@@ -20,19 +20,28 @@ def _assert_evidence_fields(job, *, expected_keys):
|
||||
|
||||
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")
|
||||
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"})
|
||||
_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")
|
||||
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]
|
||||
@@ -40,12 +49,24 @@ def test_greenhouse_adapter_detail_supports_changed_markup():
|
||||
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"})
|
||||
_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")
|
||||
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
|
||||
@@ -55,7 +76,10 @@ def test_lever_adapter_listing_extracts_open_jobs_and_skips_closed():
|
||||
|
||||
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")
|
||||
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]
|
||||
@@ -67,7 +91,9 @@ def test_lever_adapter_detail_supports_changed_markup():
|
||||
|
||||
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/" )
|
||||
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
|
||||
@@ -76,7 +102,10 @@ def test_recruitee_adapter_listing_extracts_open_jobs_and_skips_closed():
|
||||
|
||||
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")
|
||||
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]
|
||||
@@ -87,7 +116,10 @@ def test_recruitee_adapter_detail_supports_changed_markup():
|
||||
|
||||
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")
|
||||
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
|
||||
@@ -96,7 +128,10 @@ def test_smartrecruiters_adapter_listing_extracts_open_jobs_and_skips_closed():
|
||||
|
||||
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")
|
||||
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]
|
||||
@@ -106,7 +141,10 @@ def test_smartrecruiters_adapter_detail_supports_changed_markup():
|
||||
|
||||
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")
|
||||
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
|
||||
@@ -115,7 +153,10 @@ def test_workable_adapter_listing_extracts_open_jobs_and_skips_closed():
|
||||
|
||||
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/")
|
||||
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]
|
||||
|
||||
@@ -38,12 +38,15 @@ def test_discovery_from_html_includes_jsonld_and_feed_links():
|
||||
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 (
|
||||
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
|
||||
@@ -69,14 +72,16 @@ def test_discovery_from_sitemap_prefers_candidates_and_filters_private_or_denied
|
||||
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 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 [item.url for item in result] == ["https://jobs.example.org/"]
|
||||
assert result[0].source_type == Source.Type.EMPLOYER
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from apps.jobs.services.normalization import CanonicalJobDraft
|
||||
from apps.sources.models import Source
|
||||
|
||||
|
||||
def _draft_for_test(job: JobPosting, *, **overrides) -> CanonicalJobDraft:
|
||||
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",
|
||||
|
||||
@@ -27,7 +27,9 @@ def test_learning_skip_for_implicit_hide(profile, job, user):
|
||||
|
||||
|
||||
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")
|
||||
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"
|
||||
|
||||
@@ -6,8 +6,8 @@ from apps.sources.services.fetcher import (
|
||||
ContentRejectedError,
|
||||
FetchError,
|
||||
FetchTimeoutError,
|
||||
RateLimitedError,
|
||||
PolicyBlockedError,
|
||||
RateLimitedError,
|
||||
fetch_url,
|
||||
)
|
||||
from apps.sources.services.url_security import ValidatedUrl
|
||||
@@ -79,7 +79,9 @@ def test_fetcher_rejects_dns_rebinding(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=("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)
|
||||
@@ -133,7 +135,9 @@ def test_fetcher_rejects_policy_content_size_and_http(monkeypatch):
|
||||
client.close()
|
||||
|
||||
client = httpx.Client(
|
||||
transport=httpx.MockTransport(lambda request: (_ for _ in ()).throw(httpx.TimeoutException("timeout")))
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: (_ for _ in ()).throw(httpx.TimeoutException("timeout"))
|
||||
)
|
||||
)
|
||||
with pytest.raises(FetchTimeoutError):
|
||||
fetch_url("https://example.org/slow", client=client)
|
||||
|
||||
@@ -127,8 +127,7 @@ def test_validate_csv_geodata_flags_duplicates_and_invalid_coordinates(tmp_path:
|
||||
validate_csv_geodata(source)
|
||||
|
||||
source.write_text(
|
||||
"postal_code,municipality,region,latitude,longitude\n"
|
||||
"9000,Gent,Vlaams-Brabant,99,3.73\n"
|
||||
"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)
|
||||
|
||||
@@ -34,9 +34,7 @@ def test_manual_import_url_uses_fetch_and_succeeds(monkeypatch, user):
|
||||
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"
|
||||
)
|
||||
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"
|
||||
|
||||
@@ -19,7 +19,11 @@ def test_source_lease_blocks_concurrent_workers_and_recovers_when_expired(source
|
||||
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())
|
||||
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
|
||||
|
||||
|
||||
@@ -51,7 +55,7 @@ def test_source_lease_allows_origin_concurrency(db):
|
||||
)
|
||||
source_b = Source.objects.create(
|
||||
name="Source B",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
source_type=Source.Type.RSS,
|
||||
base_url="https://shared.example.org/careers/",
|
||||
domain=shared_domain,
|
||||
status=Source.Status.ACTIVE,
|
||||
@@ -76,8 +80,6 @@ def test_failure_backoff_uses_retry_after_and_bounds():
|
||||
policy=Source.Policy.ALLOW,
|
||||
)
|
||||
source.save()
|
||||
backoff = calculate_failure_backoff_seconds(
|
||||
source, failure_count=2, retry_after_seconds=120
|
||||
)
|
||||
backoff = calculate_failure_backoff_seconds(source, failure_count=2, retry_after_seconds=120)
|
||||
assert backoff >= 120
|
||||
assert backoff <= 3600 + 45
|
||||
|
||||
+17
-11
@@ -4,10 +4,9 @@ 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.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
|
||||
|
||||
|
||||
@@ -78,10 +77,12 @@ def test_distance_boundary_inclusief(matching_job, profile, monkeypatch):
|
||||
|
||||
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.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)
|
||||
@@ -90,12 +91,14 @@ def test_remote_job_heeft_geen_afstandsexclusie(matching_job, profile):
|
||||
|
||||
|
||||
def test_onbekende_locatie_leidt_niet_tot_automatische_uitsluiting(matching_job, profile):
|
||||
matching_job.postal_code = None
|
||||
matching_job.municipality = None
|
||||
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"])
|
||||
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
|
||||
@@ -162,7 +165,10 @@ def test_ai_score_influence_is_opt_in_and_bounded(matching_job, profile, monkeyp
|
||||
)
|
||||
|
||||
result = calculate_score(matching_job, profile)
|
||||
assert result.recommendation in {ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE}
|
||||
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
|
||||
|
||||
@@ -19,7 +19,14 @@ def test_validate_production_security_skips_when_debug_mode_is_enabled():
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret_key, allowed_hosts, csrf_trusted_origins, session_cookie_secure, csrf_cookie_secure, secure_ssl_redirect",
|
||||
(
|
||||
"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),
|
||||
|
||||
Reference in New Issue
Block a user