from datetime import timedelta import pytest from django.urls import reverse 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, 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_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() 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 assert client.get(reverse("jobs:applications")).status_code == 200 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): 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, ) 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 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