This commit is contained in:
+17
-2
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source
|
||||
from apps.sources.models import Source, SourcePolicyReview, SourceRobotsCache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -39,7 +41,7 @@ def profile(user):
|
||||
|
||||
@pytest.fixture
|
||||
def source(db):
|
||||
return Source.objects.create(
|
||||
source = Source.objects.create(
|
||||
name="Example jobs",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
base_url="https://jobs.example.org/vacatures/",
|
||||
@@ -47,6 +49,19 @@ def source(db):
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
)
|
||||
SourcePolicyReview.objects.create(
|
||||
source=source,
|
||||
decision=SourcePolicyReview.Decision.ALLOW,
|
||||
reason="Testbron expliciet toegestaan",
|
||||
expires_at=timezone.now() + timedelta(days=1),
|
||||
)
|
||||
SourceRobotsCache.objects.create(
|
||||
origin="https://jobs.example.org",
|
||||
expires_at=timezone.now() + timedelta(days=1),
|
||||
allow_rules={"*": ["/"]},
|
||||
disallow_rules={},
|
||||
)
|
||||
return source
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -41,7 +41,7 @@ def _assert_basic_html_accessibility(payload: bytes | str, *, page_name: str) ->
|
||||
|
||||
headings = soup.find_all("h1")
|
||||
assert headings, f"{page_name}: ontbreekt h1"
|
||||
assert len(headings) == 1, f"{page_name}: verwacht exact één h1, gevonden {len(headings)}"
|
||||
assert len(headings) == 1, f"{page_name}: verwacht exact één h1, gevonden {len(headings)}"
|
||||
|
||||
assert soup.html is not None
|
||||
assert soup.html.get("lang"), f"{page_name}: <html> mist lang-attribuut"
|
||||
@@ -53,7 +53,9 @@ def _assert_basic_html_accessibility(payload: bytes | str, *, page_name: str) ->
|
||||
field_id = field.get("id")
|
||||
has_label = bool(field.find_parent("label"))
|
||||
has_label = has_label or bool(field.get("aria-label") or field.get("aria-labelledby"))
|
||||
has_label = has_label or (field_id is not None and bool(soup.find("label", attrs={"for": field_id})))
|
||||
has_label = has_label or (
|
||||
field_id is not None and bool(soup.find("label", attrs={"for": field_id}))
|
||||
)
|
||||
|
||||
assert has_label, (
|
||||
f"{page_name}: formulierveld zonder label of aria-voorziening: "
|
||||
@@ -141,9 +143,7 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
|
||||
if sync_playwright is None:
|
||||
_run_html_accessibility_probe(client, user, profile, job)
|
||||
pytest.skip(
|
||||
"Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in."
|
||||
)
|
||||
pytest.skip("Playwright niet geïnstalleerd. Vult alleen de HTML/a11y-probe in.")
|
||||
|
||||
base = live_server.url
|
||||
dashboard_url = f"{base}{reverse('dashboard:today')}"
|
||||
@@ -227,9 +227,7 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
page.get_by_role("link", name="Brongezondheid").click()
|
||||
page.wait_for_url(sources_url)
|
||||
page.locator("textarea[name='pasted_text']").fill(
|
||||
"Vacaturetitel: Infrastructure Engineer\n"
|
||||
"Werkgever: Example IT\n"
|
||||
"Locatie: Hasselt"
|
||||
"Vacaturetitel: Infrastructure Engineer\nWerkgever: Example IT\nLocatie: Hasselt"
|
||||
)
|
||||
page.get_by_role("button", name="Import uitvoeren").click()
|
||||
page.wait_for_selector("text=Importresultaat")
|
||||
|
||||
@@ -18,7 +18,9 @@ from apps.jobs.services.feedback import record_feedback
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_applied_feedback_creates_sanitized_application_snapshot_and_timeline(job, profile, source, user):
|
||||
def test_applied_feedback_creates_sanitized_application_snapshot_and_timeline(
|
||||
job, profile, source, user
|
||||
):
|
||||
JobSourceAlias.objects.create(
|
||||
job=job,
|
||||
source=source,
|
||||
@@ -43,14 +45,20 @@ def test_applied_feedback_creates_sanitized_application_snapshot_and_timeline(jo
|
||||
assert snapshot["sources"][0]["is_canonical"] is True
|
||||
assert snapshot["scores"] is None
|
||||
|
||||
event_types = set(ApplicationTimelineEvent.objects.filter(application=application).values_list("event_type", flat=True))
|
||||
event_types = set(
|
||||
ApplicationTimelineEvent.objects.filter(application=application).values_list(
|
||||
"event_type", flat=True
|
||||
)
|
||||
)
|
||||
assert ApplicationTimelineEvent.EventType.CREATED in event_types
|
||||
assert ApplicationTimelineEvent.EventType.SNAPSHOT_CAPTURED in event_types
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_application_update_creates_timeline_events_for_status_notes_and_contact(client, job, profile, source, user):
|
||||
def test_application_update_creates_timeline_events_for_status_notes_and_contact(
|
||||
client, job, profile, source, user
|
||||
):
|
||||
record_feedback(user=user, job=job, action="applied")
|
||||
application = Application.objects.get(user=user, job=job)
|
||||
|
||||
@@ -71,7 +79,11 @@ def test_application_update_creates_timeline_events_for_status_notes_and_contact
|
||||
assert application.contact_name == "Sofie Van Hecke"
|
||||
assert application.contact_email == "recruiter@example.invalid"
|
||||
|
||||
event_types = set(ApplicationTimelineEvent.objects.filter(application=application).values_list("event_type", flat=True))
|
||||
event_types = set(
|
||||
ApplicationTimelineEvent.objects.filter(application=application).values_list(
|
||||
"event_type", flat=True
|
||||
)
|
||||
)
|
||||
assert ApplicationTimelineEvent.EventType.STATUS_CHANGED in event_types
|
||||
assert ApplicationTimelineEvent.EventType.NOTES_UPDATED in event_types
|
||||
assert ApplicationTimelineEvent.EventType.CONTACT_UPDATED in event_types
|
||||
@@ -136,7 +148,10 @@ def test_application_export_generates_sanitized_zip_payload(client, job, source,
|
||||
assert application_payload["snapshot"]["sources"][0]["url"] == job.canonical_url
|
||||
|
||||
csv_rows = list(csv.DictReader(io.StringIO(archive.read("timeline.csv").decode("utf-8"))))
|
||||
assert {row["type"] for row in csv_rows} >= {ApplicationTimelineEvent.EventType.CREATED, ApplicationTimelineEvent.EventType.SNAPSHOT_CAPTURED}
|
||||
assert {row["type"] for row in csv_rows} >= {
|
||||
ApplicationTimelineEvent.EventType.CREATED,
|
||||
ApplicationTimelineEvent.EventType.SNAPSHOT_CAPTURED,
|
||||
}
|
||||
|
||||
html = archive.read("application_print.html").decode("utf-8")
|
||||
assert "<html" in html.lower()
|
||||
@@ -170,12 +185,18 @@ def test_application_export_and_delete_are_scoped_and_idempotent(client, job, us
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
assert client.get(
|
||||
reverse("jobs:application-export", kwargs={"pk": other_application.pk})
|
||||
).status_code == 404
|
||||
assert client.post(
|
||||
reverse("jobs:application-delete", kwargs={"pk": other_application.pk})
|
||||
).status_code == 404
|
||||
assert (
|
||||
client.get(
|
||||
reverse("jobs:application-export", kwargs={"pk": other_application.pk})
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
reverse("jobs:application-delete", kwargs={"pk": other_application.pk})
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
delete_response = client.post(
|
||||
reverse("jobs:application-delete", kwargs={"pk": owner_application.pk}),
|
||||
|
||||
@@ -2,13 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
import pytest
|
||||
|
||||
from apps.jobs.models import ScoreRun
|
||||
from apps.jobs.services.scoring import score_and_save
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import_geodata_validate_only_and_import_command(tmp_path):
|
||||
csv_path = tmp_path / "geodata.csv"
|
||||
@@ -58,18 +59,18 @@ def test_import_geodata_rejects_half_license_metadata(tmp_path):
|
||||
"postal_code,municipality,region,latitude,longitude\n9000,Gent,Vlaams-Brabant,51.05,3.73\n"
|
||||
)
|
||||
out = StringIO()
|
||||
with pytest.raises(CommandError):
|
||||
call_command(
|
||||
"import_geodata",
|
||||
str(csv_path),
|
||||
"--source-name",
|
||||
with pytest.raises(CommandError):
|
||||
call_command(
|
||||
"import_geodata",
|
||||
str(csv_path),
|
||||
"--source-name",
|
||||
"local-test",
|
||||
"--dataset-version",
|
||||
"2026-01-01",
|
||||
"--license-name",
|
||||
"Test Dataset",
|
||||
stdout=out,
|
||||
)
|
||||
"--license-name",
|
||||
"Test Dataset",
|
||||
stdout=out,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -34,6 +34,7 @@ def test_pipeline_is_idempotent_and_scores(source, profile):
|
||||
assert ScoreRun.objects.filter(profile=profile).count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_pipeline_resolves_recruiter_alias_to_direct_employer():
|
||||
source = Source.objects.create(
|
||||
name="SmartRecruiters",
|
||||
@@ -76,14 +77,32 @@ def test_pipeline_resolves_recruiter_alias_to_direct_employer():
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "JobPosting",
|
||||
"identifier": {"@type": "PropertyValue", "name": "Example Public IT", "value": "VR-DEMO-SEC-001"},
|
||||
"identifier": {
|
||||
"@type": "PropertyValue",
|
||||
"name": "Example Public IT",
|
||||
"value": "VR-DEMO-SEC-001"
|
||||
},
|
||||
"title": "Security Engineer",
|
||||
"description": "<p>Beveiliging, monitoring en hybride support.</p>",
|
||||
"datePosted": "2026-07-20",
|
||||
"validThrough": "2026-08-31T23:59:00+02:00",
|
||||
"employmentType": ["FULL_TIME"],
|
||||
"hiringOrganization": {"@type": "Organization", "name": "Example Public IT", "sameAs": "https://www.example.org"},
|
||||
"jobLocation": {"@type": "Place", "address": {"@type": "PostalAddress", "streetAddress": "Voorbeeldstraat 1", "addressLocality": "Antwerpen", "addressRegion": "Antwerpen", "postalCode": "2000", "addressCountry": "BE"}},
|
||||
"hiringOrganization": {
|
||||
"@type": "Organization",
|
||||
"name": "Example Public IT",
|
||||
"sameAs": "https://www.example.org"
|
||||
},
|
||||
"jobLocation": {
|
||||
"@type": "Place",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Voorbeeldstraat 1",
|
||||
"addressLocality": "Antwerpen",
|
||||
"addressRegion": "Antwerpen",
|
||||
"postalCode": "2000",
|
||||
"addressCountry": "BE"
|
||||
}
|
||||
},
|
||||
"jobLocationType": "HYBRID",
|
||||
"url": "/example/security-engineer"
|
||||
}
|
||||
@@ -114,7 +133,4 @@ def test_pipeline_resolves_recruiter_alias_to_direct_employer():
|
||||
assert payload is not None
|
||||
assert payload["reason"] == "resolved_direct_match"
|
||||
assert payload["resolved_direct"] is True
|
||||
assert (
|
||||
payload["canonical_url"]
|
||||
== "https://jobs.example.org/vacatures/security-engineer"
|
||||
)
|
||||
assert payload["canonical_url"] == "https://jobs.example.org/vacatures/security-engineer"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, time
|
||||
from zoneinfo import ZoneInfo
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.sources.models import RawDocument, Source, SourceRun
|
||||
from apps.sources.services.health import collect_source_health
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.sources.services.fetcher import FetchedDocument
|
||||
from apps.sources.services.manual_import import ManualImportError
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from django.utils import timezone
|
||||
from apps.jobs.models import Feedback, ScoreRun
|
||||
from apps.jobs.tasks import rescore_active_jobs, update_job_lifecycle
|
||||
from apps.notifications.tasks import build_due_digests
|
||||
from apps.sources.models import RawDocument, Source, SourcePolicyReview, SourceLease, SourceRun
|
||||
from apps.sources.services.fetcher import RateLimitedError, FetchedDocument
|
||||
from apps.sources.models import RawDocument, Source, SourceLease, SourcePolicyReview, SourceRun
|
||||
from apps.sources.services.fetcher import FetchedDocument, RateLimitedError
|
||||
from apps.sources.tasks import cleanup_raw_documents, fetch_source, schedule_due_sources
|
||||
|
||||
|
||||
@@ -181,7 +181,17 @@ def test_sources_candidate_bulk_actions_and_evidence_view(client, user):
|
||||
domain="jobs.example.org",
|
||||
status=Source.Status.CANDIDATE,
|
||||
policy=Source.Policy.REVIEW,
|
||||
metadata={"discovery": [{"url": "https://jobs.example.org/careers", "discovered_from": "html", "reason": "career-link", "confidence": 0.9, "label": "Werken bij"}]},
|
||||
metadata={
|
||||
"discovery": [
|
||||
{
|
||||
"url": "https://jobs.example.org/careers",
|
||||
"discovered_from": "html",
|
||||
"reason": "career-link",
|
||||
"confidence": 0.9,
|
||||
"label": "Werken bij",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
other = Source.objects.create(
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.utils import timezone
|
||||
|
||||
from apps.sources.models import Source, SourcePolicyReview
|
||||
from apps.sources.services.policy import assess_url, create_policy_review, is_denied_domain
|
||||
from apps.sources.services.robots import RobotsDecision
|
||||
|
||||
|
||||
def test_platform_domains_are_denied_including_subdomains():
|
||||
@@ -74,7 +75,11 @@ def test_policy_conflict_denies_even_when_allow_policy_set(db):
|
||||
|
||||
|
||||
@pytest.mark.security
|
||||
def test_trial_review_can_allow_fetch(db):
|
||||
def test_trial_review_can_allow_fetch(db, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.services.policy.assess_robots",
|
||||
lambda *args, **kwargs: RobotsDecision(True, "Testrobots staan toegang toe"),
|
||||
)
|
||||
source = Source.objects.create(
|
||||
name="Example jobs",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
|
||||
@@ -6,10 +6,24 @@ from django.utils import timezone
|
||||
|
||||
from apps.sources.models import Source, SourceRobotsCache
|
||||
from apps.sources.services.robots import assess_robots
|
||||
from apps.sources.services.url_security import ValidatedUrl
|
||||
|
||||
|
||||
def _allow_test_dns(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.services.robots.validate_public_url",
|
||||
lambda url, **kwargs: ValidatedUrl(
|
||||
url=url,
|
||||
hostname="jobs.example.org",
|
||||
port=443,
|
||||
addresses=("93.184.216.34",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.security
|
||||
def test_robots_uses_user_agent_matching_and_allows_default(db):
|
||||
def test_robots_uses_user_agent_matching_and_allows_default(db, monkeypatch):
|
||||
_allow_test_dns(monkeypatch)
|
||||
source = Source.objects.create(
|
||||
name="Example jobs",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
@@ -45,7 +59,8 @@ def test_robots_uses_user_agent_matching_and_allows_default(db):
|
||||
|
||||
|
||||
@pytest.mark.security
|
||||
def test_robots_cache_refreshes_after_ttl(db, settings):
|
||||
def test_robots_cache_refreshes_after_ttl(db, settings, monkeypatch):
|
||||
_allow_test_dns(monkeypatch)
|
||||
source = Source.objects.create(
|
||||
name="Example jobs",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
@@ -63,7 +78,9 @@ def test_robots_cache_refreshes_after_ttl(db, settings):
|
||||
return httpx.Response(200, text="User-agent: *\nAllow: /")
|
||||
|
||||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
assess_robots(source.base_url, source=source, user_agent="test-agent", client=client, now=timezone.now())
|
||||
assess_robots(
|
||||
source.base_url, source=source, user_agent="test-agent", client=client, now=timezone.now()
|
||||
)
|
||||
assert calls["count"] == 1
|
||||
assess_robots(
|
||||
source.base_url,
|
||||
|
||||
+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