@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user