337 lines
12 KiB
Python
337 lines
12 KiB
Python
from datetime import timedelta
|
|
|
|
import pytest
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from apps.jobs.models import Application, 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, SourceLease, SourcePolicyReview, SourceRun
|
|
from apps.sources.services.fetcher import FetchedDocument, RateLimitedError
|
|
from apps.sources.tasks import cleanup_raw_documents, fetch_source, schedule_due_sources
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_task_helpers(profile, job, source, monkeypatch):
|
|
result = rescore_active_jobs(profile.pk)
|
|
assert result["scores_created"] == 1
|
|
assert ScoreRun.objects.filter(job=job, profile=profile).exists()
|
|
assert update_job_lifecycle() == {"expired": 0, "uncertain": 0, "removed": 0}
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
"apps.sources.tasks.fetch_source.delay", lambda source_id: calls.append(source_id)
|
|
)
|
|
source.next_run_at = timezone.now() - timedelta(minutes=1)
|
|
source.save(update_fields=["next_run_at"])
|
|
assert schedule_due_sources()["scheduled"] == 1
|
|
assert calls == [source.pk]
|
|
|
|
old = RawDocument.objects.create(
|
|
source=source,
|
|
kind=RawDocument.Kind.TEXT,
|
|
content_hash="8" * 64,
|
|
retain_until=timezone.now() - timedelta(days=1),
|
|
)
|
|
assert cleanup_raw_documents()["deleted"] >= 1
|
|
assert not RawDocument.objects.filter(pk=old.pk).exists()
|
|
|
|
source.policy = Source.Policy.DENY
|
|
source.policy_reason = "test"
|
|
source.save(update_fields=["policy", "policy_reason"])
|
|
assert fetch_source(source.pk)["status"] == "skipped"
|
|
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
|
|
|
|
|
|
def test_fetch_source_skips_when_lease_blocked(db, source, monkeypatch):
|
|
SourceLease.objects.create(
|
|
source=source,
|
|
token="active-worker",
|
|
worker_id="active-worker",
|
|
expires_at=timezone.now() + timedelta(minutes=2),
|
|
)
|
|
calls = {"count": 0}
|
|
|
|
def fake_fetch_url(*args, **kwargs):
|
|
calls["count"] += 1
|
|
return FetchedDocument(
|
|
requested_url=source.base_url,
|
|
final_url=source.base_url,
|
|
status_code=200,
|
|
headers={},
|
|
content=b"Vacature",
|
|
)
|
|
|
|
monkeypatch.setattr("apps.sources.tasks.fetch_url", fake_fetch_url)
|
|
assert fetch_source(source.pk)["status"] == "skipped"
|
|
assert calls["count"] == 0
|
|
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
|
|
|
|
|
|
def test_fetch_source_handles_rate_limit_backoff(db, source, monkeypatch):
|
|
called = {"count": 0}
|
|
|
|
def fake_fetch_url(*args, **kwargs):
|
|
called["count"] += 1
|
|
raise RateLimitedError(30)
|
|
|
|
monkeypatch.setattr("apps.sources.tasks.fetch_url", fake_fetch_url)
|
|
result = fetch_source(source.pk)
|
|
assert result["status"] == "rate_limited"
|
|
assert called["count"] == 1
|
|
source.refresh_from_db()
|
|
assert source.next_run_at is not None
|
|
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.FAILED).exists()
|
|
|
|
|
|
def test_fetch_source_resets_fail_state_on_304(db, source, monkeypatch):
|
|
source.failure_count = 4
|
|
source.save(update_fields=["failure_count"])
|
|
|
|
def fake_fetch_url(*args, **kwargs):
|
|
return FetchedDocument(
|
|
requested_url=source.base_url,
|
|
final_url=source.base_url,
|
|
status_code=304,
|
|
headers={},
|
|
content=b"",
|
|
)
|
|
|
|
monkeypatch.setattr("apps.sources.tasks.fetch_url", fake_fetch_url)
|
|
assert fetch_source(source.pk)["status"] == "not_modified"
|
|
source.refresh_from_db()
|
|
assert source.failure_count == 0
|
|
|
|
|
|
def test_due_source_scheduler_never_schedules_mailbox_sources(db, monkeypatch):
|
|
from apps.sources.tasks import schedule_due_sources
|
|
|
|
email_source = Source.objects.create(
|
|
name="Mailboxbron",
|
|
source_type=Source.Type.EMAIL,
|
|
base_url="",
|
|
domain="example.mailbox.local",
|
|
status=Source.Status.ACTIVE,
|
|
policy=Source.Policy.ALLOW,
|
|
)
|
|
scheduled = []
|
|
monkeypatch.setattr(
|
|
"apps.sources.tasks.fetch_source.delay", lambda source_id: scheduled.append(source_id)
|
|
)
|
|
|
|
result = schedule_due_sources()
|
|
|
|
assert email_source.pk not in scheduled
|
|
assert result["scheduled"] == len(scheduled)
|
|
|
|
|
|
def test_mailbox_scheduler_excludes_public_demo_user(db, monkeypatch, settings):
|
|
from django.contrib.auth import get_user_model
|
|
|
|
from apps.sources.models import MailboxConnection
|
|
from apps.sources.tasks import schedule_mailbox_polls
|
|
|
|
demo = get_user_model().objects.create_user(username=settings.DEMO_USERNAME)
|
|
connection = MailboxConnection.objects.create(
|
|
user=demo,
|
|
platform="vdab",
|
|
provider="outlook",
|
|
username="demo@example.test",
|
|
mailbox="Demo/VDAB",
|
|
enabled=True,
|
|
next_poll_at=timezone.now() - timedelta(minutes=1),
|
|
)
|
|
scheduled = []
|
|
monkeypatch.setattr(
|
|
"apps.sources.tasks.poll_mailbox.delay",
|
|
lambda connection_id: scheduled.append(connection_id),
|
|
)
|
|
|
|
schedule_mailbox_polls()
|
|
|
|
assert connection.pk not in scheduled
|
|
|
|
|
|
def test_fetch_source_is_blocked_by_conflicting_review(db):
|
|
source = Source.objects.create(
|
|
name="Blocked source",
|
|
source_type=Source.Type.EMPLOYER,
|
|
base_url="https://jobs.example.org/vacatures/",
|
|
domain="jobs.example.org",
|
|
status=Source.Status.ACTIVE,
|
|
policy=Source.Policy.ALLOW,
|
|
)
|
|
SourcePolicyReview.objects.create(
|
|
source=source,
|
|
decision=SourcePolicyReview.Decision.DENY,
|
|
reason="Review blokkeert deze bron",
|
|
)
|
|
result = fetch_source(source.pk)
|
|
assert result["status"] == "skipped"
|
|
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
|
|
|
|
|
|
def test_fetch_source_is_blocked_by_expired_review(db):
|
|
source = Source.objects.create(
|
|
name="Expired review source",
|
|
source_type=Source.Type.EMPLOYER,
|
|
base_url="https://jobs.example.org/vacatures/",
|
|
domain="jobs.example.org",
|
|
status=Source.Status.ACTIVE,
|
|
policy=Source.Policy.ALLOW,
|
|
)
|
|
SourcePolicyReview.objects.create(
|
|
source=source,
|
|
decision=SourcePolicyReview.Decision.ALLOW,
|
|
reason="Review verlopen",
|
|
expires_at=timezone.now() - timedelta(days=1),
|
|
)
|
|
result = fetch_source(source.pk)
|
|
assert result["status"] == "skipped"
|
|
assert SourceRun.objects.filter(source=source, status=SourceRun.Status.SKIPPED).exists()
|
|
|
|
# Outside the digest window this is a safe no-op.
|
|
assert build_due_digests()["sent"] == 0
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.django_db
|
|
def test_job_profile_source_and_application_views(client, user, profile, job, source):
|
|
client.force_login(user)
|
|
assert client.get(reverse("jobs:list"), {"q": "Infrastructure"}).status_code == 200
|
|
assert client.get(reverse("jobs:detail", kwargs={"pk": job.pk})).status_code == 200
|
|
response = client.post(
|
|
reverse("jobs:feedback", kwargs={"pk": job.pk}),
|
|
{"action": Feedback.Action.INTERESTING},
|
|
)
|
|
assert response.status_code == 302
|
|
assert Feedback.objects.filter(user=user, job=job).exists()
|
|
|
|
unsafe_redirect = client.post(
|
|
reverse("jobs:feedback", kwargs={"pk": job.pk}),
|
|
{
|
|
"action": Feedback.Action.INTERESTING,
|
|
"next": "https://malicious.example/steal-session",
|
|
},
|
|
)
|
|
assert unsafe_redirect.url == reverse("jobs:detail", kwargs={"pk": job.pk})
|
|
|
|
assert client.get(reverse("profiles:list")).status_code == 200
|
|
assert client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})).status_code == 200
|
|
assert client.get(reverse("sources:list")).status_code == 200
|
|
assert client.get(reverse("dashboard:system")).status_code == 200
|
|
applications_response = client.get(reverse("jobs:applications"))
|
|
assert applications_response.status_code == 200
|
|
assert [column["status"] for column in applications_response.context["pipeline_columns"]] == [
|
|
status for status, _label in Application.Status.choices
|
|
]
|
|
|
|
source.policy = Source.Policy.DENY
|
|
source.save(update_fields=["policy"])
|
|
response = client.post(reverse("sources:retry", kwargs={"pk": source.pk}))
|
|
assert response.status_code == 302
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.django_db
|
|
def test_sources_candidate_bulk_actions_and_evidence_view(client, user, profile):
|
|
profile.home_postal_code = "2400"
|
|
profile.max_distance_km = 40
|
|
profile.home_latitude = None
|
|
profile.home_longitude = None
|
|
profile.save(
|
|
update_fields=[
|
|
"home_postal_code",
|
|
"max_distance_km",
|
|
"home_latitude",
|
|
"home_longitude",
|
|
"updated_at",
|
|
]
|
|
)
|
|
candidate = Source.objects.create(
|
|
name="Discoverd candidate",
|
|
source_type=Source.Type.EMPLOYER,
|
|
base_url="https://jobs.example.org/vacatures/",
|
|
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",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
other = Source.objects.create(
|
|
name="Another source",
|
|
source_type=Source.Type.EMPLOYER,
|
|
base_url="https://other.example.org/",
|
|
domain="other.example.org",
|
|
status=Source.Status.CANDIDATE,
|
|
policy=Source.Policy.REVIEW,
|
|
)
|
|
watchlist = Source.objects.create(
|
|
name="Interessante werkgever zonder stabiele feed",
|
|
source_type=Source.Type.EMPLOYER,
|
|
base_url="https://watch.example.org/jobs/",
|
|
domain="watch.example.org",
|
|
status=Source.Status.PAUSED,
|
|
policy=Source.Policy.REVIEW,
|
|
metadata={
|
|
"watchlist": True,
|
|
"municipality_scope": "Olen",
|
|
"watchlist_reason": "Regionaal interessante werkgever",
|
|
"current_observation": "Momenteel geen lokale vacature bevestigd.",
|
|
"public_job_page": "https://watch.example.org/jobs/",
|
|
},
|
|
)
|
|
|
|
client.force_login(user)
|
|
response = client.get(reverse("sources:list"))
|
|
assert response.status_code == 200
|
|
content = response.content.decode("utf-8")
|
|
assert "jobs.example.org/careers" in content
|
|
assert "Platformmailboxen" in content
|
|
assert "Mailbox koppelen en testen" in content
|
|
assert "elke vijf minuten" in content
|
|
assert "2400 Mol" in content
|
|
assert "maximaal 40 km" in content
|
|
assert "Coördinaten ontbreken nog" in content
|
|
assert "Interessante werkgeverswaaklijst" in content
|
|
assert watchlist.name in content
|
|
assert response.context["watchlist_count"] == 1
|
|
assert {item.pk for item in response.context["sources"]} == {candidate.pk, other.pk}
|
|
|
|
response = client.post(
|
|
reverse("sources:bulk"),
|
|
{"action": "approve_to_trial", "source_ids": [str(candidate.pk)]},
|
|
)
|
|
assert response.status_code == 302
|
|
candidate.refresh_from_db()
|
|
assert candidate.status == Source.Status.TRIAL
|
|
|
|
response = client.post(
|
|
reverse("sources:bulk"),
|
|
{"action": "dismiss", "source_ids": [str(other.pk)]},
|
|
)
|
|
assert response.status_code == 302
|
|
other.refresh_from_db()
|
|
assert other.status == Source.Status.DISABLED
|
|
assert other.policy == Source.Policy.DENY
|
|
|
|
response = client.post(
|
|
reverse("sources:bulk"),
|
|
{"action": "dismiss", "source_ids": [str(watchlist.pk)]},
|
|
)
|
|
assert response.status_code == 302
|
|
watchlist.refresh_from_db()
|
|
assert watchlist.status == Source.Status.PAUSED
|
|
assert watchlist.policy == Source.Policy.REVIEW
|