@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import Application, ApplicationTimelineEvent, JobSourceAlias, ScoreRun
|
||||
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):
|
||||
JobSourceAlias.objects.create(
|
||||
job=job,
|
||||
source=source,
|
||||
url=job.canonical_url,
|
||||
canonical_url=job.canonical_url,
|
||||
external_id="ext-applied-1",
|
||||
source_title="Applied page",
|
||||
source_employer=job.employer_name,
|
||||
is_canonical=True,
|
||||
extraction_method="manual-test",
|
||||
)
|
||||
record_feedback(user=user, job=job, action="applied")
|
||||
|
||||
application = Application.objects.get(user=user, job=job)
|
||||
snapshot = application.snapshot
|
||||
assert snapshot["schema_version"] == "1.0.0"
|
||||
assert snapshot["snapshot_kind"] == "application"
|
||||
assert snapshot["title"] == job.original_title
|
||||
assert snapshot["job"]["id"] == str(job.pk)
|
||||
assert snapshot["job"]["description_html"] == job.description_html_sanitized
|
||||
assert snapshot["sources"][0]["url"] == job.canonical_url
|
||||
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))
|
||||
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):
|
||||
record_feedback(user=user, job=job, action="applied")
|
||||
application = Application.objects.get(user=user, job=job)
|
||||
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("jobs:application-edit", kwargs={"pk": application.pk}),
|
||||
{
|
||||
"status": Application.Status.INTERVIEW,
|
||||
"contact_name": "Sofie Van Hecke",
|
||||
"contact_email": "recruiter@example.invalid",
|
||||
"notes": "Eerste follow-up gepland voor vrijdag.",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
application.refresh_from_db()
|
||||
assert application.status == Application.Status.INTERVIEW
|
||||
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))
|
||||
assert ApplicationTimelineEvent.EventType.STATUS_CHANGED in event_types
|
||||
assert ApplicationTimelineEvent.EventType.NOTES_UPDATED in event_types
|
||||
assert ApplicationTimelineEvent.EventType.CONTACT_UPDATED in event_types
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_application_export_generates_sanitized_zip_payload(client, job, source, user, profile):
|
||||
JobSourceAlias.objects.create(
|
||||
job=job,
|
||||
source=source,
|
||||
url=job.canonical_url,
|
||||
canonical_url=job.canonical_url,
|
||||
external_id="ext-export-1",
|
||||
source_title="Export page",
|
||||
source_employer=job.employer_name,
|
||||
is_canonical=True,
|
||||
extraction_method="manual-test",
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=1,
|
||||
score=Decimal("84.20"),
|
||||
confidence=Decimal("0.71"),
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
components={"content": 0.41},
|
||||
positives=["python"],
|
||||
concerns=["travel"],
|
||||
hard_exclusions=[],
|
||||
evidence={"notes": "Voorbeeldscore"},
|
||||
model_version="testsuite",
|
||||
prompt_version="1.0.0",
|
||||
)
|
||||
record_feedback(user=user, job=job, action="applied")
|
||||
record_feedback(user=user, job=job, action="applied")
|
||||
application = Application.objects.get(user=user, job=job)
|
||||
|
||||
application.notes = "Vaste follow-up notitie."
|
||||
application.save(update_fields=["notes", "updated_at"])
|
||||
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("jobs:application-export", kwargs={"pk": application.pk}))
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/zip"
|
||||
assert 'filename="' in response["Content-Disposition"]
|
||||
|
||||
filename = response["Content-Disposition"].partition("filename=")[2].strip().strip('"')
|
||||
assert filename.startswith(f"application-{application.pk}-")
|
||||
assert filename.endswith(".zip")
|
||||
assert ".." not in filename and "/" not in filename and "\\" not in filename
|
||||
|
||||
archive = zipfile.ZipFile(io.BytesIO(response.content))
|
||||
members = sorted(archive.namelist())
|
||||
assert members == ["application.json", "application_print.html", "timeline.csv"]
|
||||
|
||||
application_payload = json.loads(archive.read("application.json").decode("utf-8"))
|
||||
assert application_payload["application"]["id"] == str(application.pk)
|
||||
assert application_payload["application"]["job_id"] == str(job.pk)
|
||||
assert application_payload["application"]["status"] == Application.Status.APPLIED
|
||||
assert application_payload["snapshot"]["scores"]["score"] == 84.2
|
||||
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}
|
||||
|
||||
html = archive.read("application_print.html").decode("utf-8")
|
||||
assert "<html" in html.lower()
|
||||
assert "<link " not in html.lower()
|
||||
assert "<script" not in html.lower()
|
||||
|
||||
assert "raw body" not in json.dumps(application_payload)
|
||||
|
||||
response_json = client.get(reverse("jobs:application-print", kwargs={"pk": application.pk}))
|
||||
assert response_json.status_code == 200
|
||||
assert response_json["Content-Type"] == "text/html; charset=utf-8"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_application_export_and_delete_are_scoped_and_idempotent(client, job, user, source):
|
||||
other_user = get_user_model().objects.create_user(
|
||||
username="other",
|
||||
email="other@example.invalid",
|
||||
password="correct-horse-battery-staple",
|
||||
)
|
||||
|
||||
record_feedback(user=user, job=job, action="applied")
|
||||
owner_application = Application.objects.get(user=user, job=job)
|
||||
other_application = Application.objects.create(
|
||||
user=other_user,
|
||||
job=job,
|
||||
status=Application.Status.APPLIED,
|
||||
applied_at=timezone.now() - timedelta(days=1),
|
||||
follow_up_date=timezone.now().date(),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
delete_response = client.post(
|
||||
reverse("jobs:application-delete", kwargs={"pk": owner_application.pk}),
|
||||
follow=True,
|
||||
)
|
||||
assert delete_response.status_code == 200
|
||||
assert not Application.objects.filter(pk=owner_application.pk).exists()
|
||||
|
||||
repeat_delete = client.post(
|
||||
reverse("jobs:application-delete", kwargs={"pk": owner_application.pk}),
|
||||
follow=True,
|
||||
)
|
||||
assert repeat_delete.status_code == 200
|
||||
@@ -0,0 +1,147 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import Application, Feedback, JobPosting, JobSourceAlias
|
||||
from apps.jobs.services.dedupe import candidate_similarity, find_existing_job, text_similarity
|
||||
from apps.jobs.services.feedback import record_feedback
|
||||
from apps.jobs.services.lifecycle import update_lifecycle
|
||||
from apps.jobs.services.normalization import CanonicalJobDraft
|
||||
from apps.sources.models import RawDocument
|
||||
|
||||
|
||||
def _draft(job, **overrides):
|
||||
values = {
|
||||
"source_url": job.canonical_url,
|
||||
"canonical_url": job.canonical_url,
|
||||
"external_id": "external-1",
|
||||
"title": job.original_title,
|
||||
"normalized_title": job.normalized_title,
|
||||
"job_family": job.job_family,
|
||||
"employer_name": job.employer_name,
|
||||
"employer_domain": job.employer.domain,
|
||||
"location_text": job.raw_location,
|
||||
"region": job.region,
|
||||
"municipality": job.municipality,
|
||||
"postal_code": "3500",
|
||||
"country": "BE",
|
||||
"workplace_type": job.workplace_type,
|
||||
"employment_types": job.employment_types,
|
||||
"language": "nl",
|
||||
"description_html": "",
|
||||
"description_text": job.description_text,
|
||||
"date_posted": None,
|
||||
"valid_through": None,
|
||||
"compensation": {},
|
||||
"skills_required": job.skills_required,
|
||||
"skills_preferred": job.skills_preferred,
|
||||
"content_hash": job.content_hash,
|
||||
"canonical_key": job.canonical_key,
|
||||
}
|
||||
values.update(overrides)
|
||||
return CanonicalJobDraft(**values)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dedupe_exact_fuzzy_and_new(job, source):
|
||||
document = RawDocument.objects.create(
|
||||
source=source,
|
||||
kind=RawDocument.Kind.HTML,
|
||||
content_hash="e" * 64,
|
||||
)
|
||||
JobSourceAlias.objects.create(
|
||||
job=job,
|
||||
source=source,
|
||||
raw_document=document,
|
||||
url=job.canonical_url,
|
||||
canonical_url=job.canonical_url,
|
||||
external_id="external-1",
|
||||
)
|
||||
exact = find_existing_job(_draft(job))
|
||||
assert exact.job == job
|
||||
assert exact.reason == "exact_external_id"
|
||||
|
||||
fuzzy_draft = _draft(
|
||||
job,
|
||||
external_id="",
|
||||
canonical_url="https://jobs.example.org/other-id",
|
||||
canonical_key="f" * 64,
|
||||
)
|
||||
fuzzy = find_existing_job(fuzzy_draft, threshold=0.8)
|
||||
assert fuzzy.job == job
|
||||
assert fuzzy.reason == "fuzzy_strong"
|
||||
assert candidate_similarity(job, fuzzy_draft) >= 0.8
|
||||
assert text_similarity("", "x") == 0
|
||||
|
||||
new = find_existing_job(
|
||||
_draft(
|
||||
job,
|
||||
external_id="",
|
||||
canonical_url="https://new.example.org/job",
|
||||
canonical_key="1" * 64,
|
||||
title="Chef kok",
|
||||
normalized_title="chef kok",
|
||||
employer_name="Restaurant",
|
||||
employer_domain="restaurant.example",
|
||||
description_text="Koken in een restaurant",
|
||||
)
|
||||
)
|
||||
assert new.job is None
|
||||
assert new.reason == "new"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_applied_feedback_creates_application_and_follow_up(user, profile, job):
|
||||
feedback = record_feedback(user=user, job=job, action=Feedback.Action.APPLIED, reason="goed")
|
||||
assert feedback.profile == profile
|
||||
application = Application.objects.get(user=user, job=job)
|
||||
assert application.status == Application.Status.APPLIED
|
||||
assert application.snapshot["title"] == job.original_title
|
||||
assert application.follow_up_date == timezone.localdate() + timedelta(days=7)
|
||||
|
||||
application.status = Application.Status.PREPARING
|
||||
application.save(update_fields=["status"])
|
||||
record_feedback(user=user, job=job, action=Feedback.Action.APPLIED)
|
||||
application.refresh_from_db()
|
||||
assert application.status == Application.Status.APPLIED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lifecycle_transitions(employer):
|
||||
now = timezone.now()
|
||||
expired = JobPosting.objects.create(
|
||||
employer=employer,
|
||||
original_title="Expired",
|
||||
normalized_title="expired",
|
||||
canonical_key="2" * 64,
|
||||
content_hash="3" * 64,
|
||||
valid_through=now - timedelta(minutes=1),
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
uncertain = JobPosting.objects.create(
|
||||
employer=employer,
|
||||
original_title="Uncertain",
|
||||
normalized_title="uncertain",
|
||||
canonical_key="4" * 64,
|
||||
content_hash="5" * 64,
|
||||
last_seen=now - timedelta(days=5),
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
removed = JobPosting.objects.create(
|
||||
employer=employer,
|
||||
original_title="Removed",
|
||||
normalized_title="removed",
|
||||
canonical_key="6" * 64,
|
||||
content_hash="7" * 64,
|
||||
last_seen=now - timedelta(days=20),
|
||||
status=JobPosting.Status.UNCERTAIN,
|
||||
)
|
||||
result = update_lifecycle()
|
||||
assert result == {"expired": 1, "uncertain": 1, "removed": 1}
|
||||
expired.refresh_from_db()
|
||||
uncertain.refresh_from_db()
|
||||
removed.refresh_from_db()
|
||||
assert expired.status == JobPosting.Status.EXPIRED
|
||||
assert uncertain.status == JobPosting.Status.UNCERTAIN
|
||||
assert removed.status == JobPosting.Status.REMOVED
|
||||
@@ -0,0 +1,86 @@
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.sources.models import Source
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discover_sources_command_runs_for_all_modes_and_is_replayable():
|
||||
out = StringIO()
|
||||
call_command(
|
||||
"discover_sources",
|
||||
"html",
|
||||
"--input",
|
||||
"fixtures/discovery/html_candidate_discovery.html",
|
||||
"--base-url",
|
||||
"https://example.org/",
|
||||
stdout=out,
|
||||
)
|
||||
call_command(
|
||||
"discover_sources",
|
||||
"sitemap",
|
||||
"--input",
|
||||
"fixtures/discovery/sitemap_index.xml",
|
||||
"--base-url",
|
||||
"https://jobs.example.org/",
|
||||
stdout=out,
|
||||
)
|
||||
call_command(
|
||||
"discover_sources",
|
||||
"feed",
|
||||
"--input",
|
||||
"fixtures/discovery/feed_jobs.xml",
|
||||
"--base-url",
|
||||
"https://jobs.example.org/feed/jobs",
|
||||
stdout=out,
|
||||
)
|
||||
call_command(
|
||||
"discover_sources",
|
||||
"email",
|
||||
"--input",
|
||||
"fixtures/emails/sample_alert.eml",
|
||||
stdout=out,
|
||||
)
|
||||
|
||||
source_types = {
|
||||
(source.domain, source.source_type)
|
||||
for source in Source.objects.filter(status=Source.Status.CANDIDATE)
|
||||
}
|
||||
assert ("example.org", Source.Type.EMPLOYER) in source_types
|
||||
assert ("jobs.example.org", Source.Type.EMPLOYER) in source_types
|
||||
assert ("example.org", Source.Type.RSS) in source_types
|
||||
assert ("jobs.example.org", Source.Type.SITEMAP) in source_types
|
||||
assert ("jobs.example.org", Source.Type.RSS) in source_types
|
||||
assert "Persist result" in out.getvalue()
|
||||
|
||||
out.seek(0)
|
||||
out.truncate(0)
|
||||
call_command(
|
||||
"discover_sources",
|
||||
"html",
|
||||
"--input",
|
||||
"fixtures/discovery/html_candidate_discovery.html",
|
||||
"--base-url",
|
||||
"https://example.org/",
|
||||
stdout=out,
|
||||
)
|
||||
assert Source.objects.filter(status=Source.Status.CANDIDATE).count() == 5
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discover_sources_dry_run_keeps_db_empty():
|
||||
out = StringIO()
|
||||
call_command(
|
||||
"discover_sources",
|
||||
"feed",
|
||||
"--input",
|
||||
"fixtures/discovery/feed_jobs.xml",
|
||||
"--base-url",
|
||||
"https://jobs.example.org/feed/jobs",
|
||||
"--dry-run",
|
||||
stdout=out,
|
||||
)
|
||||
assert Source.objects.count() == 0
|
||||
assert "Dry-run modus" in out.getvalue()
|
||||
@@ -0,0 +1,72 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.jobs.models import Feedback, ScoreRun
|
||||
from apps.jobs.services.scoring import score_and_save
|
||||
from apps.notifications.models import DigestOutbox
|
||||
from apps.notifications.services import build_digest_payload, create_daily_outbox, send_digest
|
||||
from apps.sources.models import EmailMessageRecord, RawDocument
|
||||
from apps.sources.services.email_import import ingest_email, message_identity
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_import_is_idempotent(profile):
|
||||
raw = Path("fixtures/emails/sample_alert.eml").read_bytes()
|
||||
identity = message_identity(raw)
|
||||
first = ingest_email(raw)
|
||||
second = ingest_email(raw)
|
||||
assert first.pk == second.pk
|
||||
assert first.message_id == identity
|
||||
assert EmailMessageRecord.objects.count() == 1
|
||||
assert RawDocument.objects.filter(kind=RawDocument.Kind.EMAIL).count() == 1
|
||||
assert len(first.links) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_digest_payload_outbox_deduplication_and_send(profile, job):
|
||||
score = score_and_save(job, profile)
|
||||
score.recommendation = ScoreRun.Recommendation.STRONG
|
||||
score.score = 92
|
||||
score.save(update_fields=["recommendation", "score"])
|
||||
|
||||
local_tz = ZoneInfo(profile.timezone)
|
||||
due = datetime(2026, 7, 20, 7, 35, tzinfo=local_tz)
|
||||
payload = build_digest_payload(profile, local_date=due.date())
|
||||
assert payload["strong_count"] == 1
|
||||
assert payload["scores"][0]["title"] == job.original_title
|
||||
|
||||
with override_settings(DIGEST_RECIPIENT="digest@example.invalid"):
|
||||
first = create_daily_outbox(profile, now=due)
|
||||
second = create_daily_outbox(profile, now=due)
|
||||
assert first is not None
|
||||
assert second.pk == first.pk
|
||||
assert first.status == DigestOutbox.Status.PENDING
|
||||
|
||||
send_digest(first)
|
||||
first.refresh_from_db()
|
||||
assert first.status == DigestOutbox.Status.SENT
|
||||
assert first.sent_at is not None
|
||||
assert len(mail.outbox) == 1
|
||||
assert "Infrastructure Engineer" in mail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_digest_skips_outside_window_and_hidden_jobs(profile, job):
|
||||
score_and_save(job, profile)
|
||||
Feedback.objects.create(
|
||||
user=profile.user,
|
||||
profile=profile,
|
||||
job=job,
|
||||
action=Feedback.Action.HIDE,
|
||||
)
|
||||
payload = build_digest_payload(profile)
|
||||
assert payload["scores"] == []
|
||||
|
||||
local_tz = ZoneInfo(profile.timezone)
|
||||
too_early = datetime(2026, 7, 20, 6, 0, tzinfo=local_tz)
|
||||
assert create_daily_outbox(profile, now=too_early) is None
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
|
||||
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"
|
||||
csv_path.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"
|
||||
)
|
||||
out = StringIO()
|
||||
call_command(
|
||||
"import_geodata",
|
||||
str(csv_path),
|
||||
"--source-name",
|
||||
"local-test",
|
||||
"--dataset-version",
|
||||
"2026-01-01",
|
||||
"--validate-only",
|
||||
"--license-name",
|
||||
"Test Dataset",
|
||||
"--license-url",
|
||||
"https://example.org/license",
|
||||
stdout=out,
|
||||
)
|
||||
assert "Validatie geslaagd (2 rijen)." in out.getvalue()
|
||||
|
||||
out = StringIO()
|
||||
call_command(
|
||||
"import_geodata",
|
||||
str(csv_path),
|
||||
"--source-name",
|
||||
"local-test",
|
||||
"--dataset-version",
|
||||
"2026-01-01",
|
||||
"--license-name",
|
||||
"Test Dataset",
|
||||
"--license-url",
|
||||
"https://example.org/license",
|
||||
stdout=out,
|
||||
)
|
||||
assert "Geocodebron geimporteerd (4 lookuprijen)" in out.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import_geodata_rejects_half_license_metadata(tmp_path):
|
||||
csv_path = tmp_path / "geodata.csv"
|
||||
csv_path.write_text(
|
||||
"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",
|
||||
"local-test",
|
||||
"--dataset-version",
|
||||
"2026-01-01",
|
||||
"--license-name",
|
||||
"Test Dataset",
|
||||
stdout=out,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import_geodata_triggers_targeted_rescore(tmp_path, profile, job):
|
||||
job.postal_code = "9000"
|
||||
job.municipality = "Gent"
|
||||
job.save(update_fields=["postal_code", "municipality"])
|
||||
|
||||
score_and_save(job, profile)
|
||||
initial_scores = ScoreRun.objects.filter(profile=profile, job=job).count()
|
||||
|
||||
csv_path = tmp_path / "geodata.csv"
|
||||
csv_path.write_text(
|
||||
"postal_code,municipality,region,latitude,longitude\n9000,Gent,Vlaams-Brabant,51.05,3.73\n"
|
||||
)
|
||||
out = StringIO()
|
||||
call_command(
|
||||
"import_geodata",
|
||||
str(csv_path),
|
||||
"--source-name",
|
||||
"local-test",
|
||||
"--dataset-version",
|
||||
"2026-01-01",
|
||||
"--license-name",
|
||||
"Test Dataset",
|
||||
"--license-url",
|
||||
"https://example.org/license",
|
||||
stdout=out,
|
||||
)
|
||||
assert "scoreregels herberekend voor aangepaste geodata" in out.getvalue()
|
||||
assert ScoreRun.objects.filter(profile=profile, job=job).count() == initial_scores + 1
|
||||
|
||||
latest = ScoreRun.objects.filter(profile=profile, job=job).order_by("-created_at").first()
|
||||
assert latest is not None
|
||||
assert latest.evidence["distance_km"] is not None
|
||||
@@ -0,0 +1,120 @@
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import Employer, JobPosting, JobSourceAlias, ScoreRun
|
||||
from apps.jobs.services.pipeline import process_raw_document
|
||||
from apps.sources.models import RawDocument, Source
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_pipeline_is_idempotent_and_scores(source, profile):
|
||||
content = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
|
||||
document = RawDocument.objects.create(
|
||||
source=source,
|
||||
url="https://jobs.example.org/vacatures/infrastructure-engineer",
|
||||
final_url="https://jobs.example.org/vacatures/infrastructure-engineer",
|
||||
kind=RawDocument.Kind.HTML,
|
||||
content_type="text/html",
|
||||
content_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||
body_text=content,
|
||||
byte_length=len(content.encode()),
|
||||
retain_until=timezone.now() + timedelta(days=7),
|
||||
)
|
||||
first = process_raw_document(document)
|
||||
second = process_raw_document(document)
|
||||
assert first["created"] == 1
|
||||
assert second["created"] == 0
|
||||
assert JobPosting.objects.count() == 1
|
||||
assert JobSourceAlias.objects.count() == 1
|
||||
assert ScoreRun.objects.filter(profile=profile).count() == 2
|
||||
|
||||
|
||||
def test_pipeline_resolves_recruiter_alias_to_direct_employer():
|
||||
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,
|
||||
)
|
||||
employer = Employer.objects.create(
|
||||
name="Example Public IT",
|
||||
normalized_name="example public it",
|
||||
domain="jobs.example.org",
|
||||
is_direct_employer=True,
|
||||
is_recruiter=False,
|
||||
confidence=0.9,
|
||||
)
|
||||
direct_job = JobPosting.objects.create(
|
||||
employer=employer,
|
||||
original_title="Security Engineer",
|
||||
normalized_title="security engineer",
|
||||
canonical_url="https://jobs.example.org/vacatures/security-engineer",
|
||||
canonical_key="8" * 64,
|
||||
content_hash="e" * 64,
|
||||
description_text="Beveiliger, hybride werk met support op locatie.",
|
||||
raw_location="Antwerpen",
|
||||
region="Antwerpen",
|
||||
municipality="Antwerpen",
|
||||
workplace_type=JobPosting.Workplace.HYBRID,
|
||||
employment_types=["full_time"],
|
||||
direct_employer=True,
|
||||
)
|
||||
content = """
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Security Engineer | Example Public IT</title>
|
||||
<link rel="canonical" href="https://jobs.smartrecruiters.com/example/security-engineer">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "JobPosting",
|
||||
"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"}},
|
||||
"jobLocationType": "HYBRID",
|
||||
"url": "/example/security-engineer"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body><main><h1>Security Engineer</h1></main></body>
|
||||
</html>
|
||||
""".strip()
|
||||
body_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
document = RawDocument.objects.create(
|
||||
source=source,
|
||||
url="https://jobs.smartrecruiters.com/example/security-engineer",
|
||||
final_url="https://jobs.smartrecruiters.com/example/security-engineer",
|
||||
kind=RawDocument.Kind.HTML,
|
||||
content_type="text/html",
|
||||
content_hash=body_hash,
|
||||
body_text=content,
|
||||
byte_length=len(content.encode()),
|
||||
retain_until=timezone.now() + timedelta(days=7),
|
||||
)
|
||||
result = process_raw_document(document)
|
||||
direct_job.refresh_from_db()
|
||||
assert direct_job.canonical_url == "https://jobs.example.org/vacatures/security-engineer"
|
||||
assert result["created"] == 0
|
||||
assert result["duplicates"] == 1
|
||||
alias = JobSourceAlias.objects.get(job=direct_job, source=source)
|
||||
payload = alias.payload.get("employer_resolution")
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
from datetime import datetime, time
|
||||
from zoneinfo import ZoneInfo
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import Application, Feedback, ScoreRun
|
||||
from apps.jobs.services.scoring import score_and_save
|
||||
from apps.notifications.models import ReminderOutbox
|
||||
from apps.notifications.services import (
|
||||
create_closing_reminders,
|
||||
create_follow_up_reminders,
|
||||
create_top_match_reminders,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_closing_reminder_is_idempotent_and_skips_hidden(profile, job):
|
||||
zone = ZoneInfo(profile.timezone)
|
||||
now = timezone.now().astimezone(zone)
|
||||
job.valid_through = datetime(
|
||||
year=now.year,
|
||||
month=now.month,
|
||||
day=now.day,
|
||||
hour=20,
|
||||
tzinfo=zone,
|
||||
)
|
||||
job.save(update_fields=["valid_through"])
|
||||
|
||||
Feedback.objects.create(
|
||||
user=profile.user,
|
||||
profile=profile,
|
||||
job=job,
|
||||
action=Feedback.Action.HIDE,
|
||||
)
|
||||
assert create_closing_reminders(profile, now=now) == []
|
||||
|
||||
Feedback.objects.filter(user=profile.user, profile=profile, job=job).delete()
|
||||
first = create_closing_reminders(profile, now=now)[0]
|
||||
second = create_closing_reminders(profile, now=now)[0]
|
||||
assert first.pk == second.pk
|
||||
assert first.status == ReminderOutbox.Status.PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_follow_up_reminder_respects_configured_weekdays(profile, job):
|
||||
zone = ZoneInfo(profile.timezone)
|
||||
now = timezone.now().astimezone(zone)
|
||||
follow_date = now.date()
|
||||
Application.objects.create(
|
||||
user=profile.user,
|
||||
job=job,
|
||||
follow_up_date=follow_date,
|
||||
status=Application.Status.APPLIED,
|
||||
notes="",
|
||||
)
|
||||
profile.follow_up_weekdays = [(follow_date.weekday() + 1) % 7]
|
||||
profile.save(update_fields=["follow_up_weekdays"])
|
||||
assert create_follow_up_reminders(profile, now=now) == []
|
||||
|
||||
profile.follow_up_weekdays = [follow_date.weekday()]
|
||||
profile.save(update_fields=["follow_up_weekdays"])
|
||||
reminders = create_follow_up_reminders(profile, now=now)
|
||||
assert len(reminders) == 1
|
||||
assert reminders[0].reminder_type == ReminderOutbox.ReminderType.FOLLOW_UP
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_match_reminder_requires_threshold_and_profile_setting(profile, job):
|
||||
profile.top_match_reminders_enabled = True
|
||||
profile.top_match_threshold = 90
|
||||
profile.save(update_fields=["top_match_reminders_enabled", "top_match_threshold"])
|
||||
score = score_and_save(job, profile)
|
||||
score.recommendation = ScoreRun.Recommendation.STRONG
|
||||
score.score = 85
|
||||
score.confidence = Decimal("0.9")
|
||||
score.save(update_fields=["recommendation", "score", "confidence"])
|
||||
|
||||
assert create_top_match_reminders(profile) == []
|
||||
|
||||
score.score = 95
|
||||
score.confidence = Decimal("0.90")
|
||||
score.save(update_fields=["score", "confidence"])
|
||||
score.confidence = Decimal("0.84")
|
||||
score.save(update_fields=["confidence"])
|
||||
assert create_top_match_reminders(profile) == []
|
||||
|
||||
score.confidence = Decimal("0.86")
|
||||
score.save(update_fields=["confidence"])
|
||||
reminders = create_top_match_reminders(profile)
|
||||
assert len(reminders) == 1
|
||||
assert reminders[0].reminder_type == ReminderOutbox.ReminderType.TOP_MATCH
|
||||
|
||||
profile.top_match_reminders_enabled = False
|
||||
profile.save(update_fields=["top_match_reminders_enabled"])
|
||||
assert create_top_match_reminders(profile) == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reminder_obeys_quiet_hours_and_empty_recipient(profile, job):
|
||||
zone = ZoneInfo(profile.timezone)
|
||||
now = timezone.now().astimezone(zone).replace(hour=6, minute=30, second=0, microsecond=0)
|
||||
job.valid_through = datetime(
|
||||
year=now.year,
|
||||
month=now.month,
|
||||
day=now.day,
|
||||
hour=18,
|
||||
tzinfo=zone,
|
||||
)
|
||||
job.save(update_fields=["valid_through"])
|
||||
|
||||
profile.user.email = ""
|
||||
profile.user.save(update_fields=["email"])
|
||||
assert create_closing_reminders(profile, now=now)[0].status == ReminderOutbox.Status.SKIPPED
|
||||
|
||||
profile.user.email = "reminder@example.invalid"
|
||||
profile.user.save(update_fields=["email"])
|
||||
reminder = create_closing_reminders(profile, now=now)[0]
|
||||
assert reminder.status == ReminderOutbox.Status.PENDING
|
||||
assert reminder.scheduled_for.timetz().replace(tzinfo=None) == time(7, 0)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reminder_service_handles_dst_dates(profile, job):
|
||||
zone = ZoneInfo(profile.timezone)
|
||||
now = datetime(2026, 10, 25, 1, 30, tzinfo=zone)
|
||||
job.valid_through = now.replace(hour=20)
|
||||
job.save(update_fields=["valid_through"])
|
||||
reminder = create_closing_reminders(profile, now=now)[0]
|
||||
assert reminder.scheduled_for.tzinfo is not None
|
||||
assert reminder.scheduled_for.tzinfo.key == "Europe/Brussels"
|
||||
@@ -0,0 +1,170 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.sources.models import RawDocument, Source, SourceRun
|
||||
from apps.sources.services.health import collect_source_health
|
||||
from apps.sources.tasks import cleanup_raw_documents, enforce_source_health, recover_source_health
|
||||
|
||||
|
||||
def _create_run(
|
||||
source: Source,
|
||||
*,
|
||||
status: str,
|
||||
started_at=None,
|
||||
error_category: str = "",
|
||||
extracted_count: int = 0,
|
||||
created_count: int = 0,
|
||||
updated_count: int = 0,
|
||||
duplicate_count: int = 0,
|
||||
http_status: int = 200,
|
||||
metrics: dict[str, object] | None = None,
|
||||
):
|
||||
started_at = started_at or timezone.now()
|
||||
finished_at = started_at + timedelta(seconds=1)
|
||||
return SourceRun.objects.create(
|
||||
source=source,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
status=status,
|
||||
error_category=error_category,
|
||||
http_status=http_status,
|
||||
extracted_count=extracted_count,
|
||||
created_count=created_count,
|
||||
updated_count=updated_count,
|
||||
duplicate_count=duplicate_count,
|
||||
metrics=metrics or {},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_enforce_source_health_quarantines_on_temporary_failures(source):
|
||||
for index in range(6):
|
||||
_create_run(
|
||||
source,
|
||||
status=SourceRun.Status.FAILED,
|
||||
started_at=timezone.now() - timedelta(minutes=index),
|
||||
error_category="timeout",
|
||||
)
|
||||
|
||||
result = enforce_source_health()
|
||||
assert result["quarantined"] == 1
|
||||
source.refresh_from_db()
|
||||
assert source.status == Source.Status.QUARANTINED
|
||||
assert source.policy == Source.Policy.DENY
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_enforce_source_health_quarantines_on_policy_category(source):
|
||||
for index in range(3):
|
||||
_create_run(
|
||||
source,
|
||||
status=SourceRun.Status.FAILED,
|
||||
started_at=timezone.now() - timedelta(minutes=index),
|
||||
error_category="policy",
|
||||
)
|
||||
result = enforce_source_health()
|
||||
assert result["quarantined"] == 1
|
||||
source.refresh_from_db()
|
||||
assert source.status == Source.Status.QUARANTINED
|
||||
assert "beleid" in source.policy_reason
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_enforce_source_health_quarantines_on_parser_drift(source):
|
||||
for index in range(3):
|
||||
_create_run(
|
||||
source,
|
||||
status=SourceRun.Status.SUCCESS,
|
||||
started_at=timezone.now() - timedelta(minutes=index),
|
||||
extracted_count=0,
|
||||
created_count=0,
|
||||
updated_count=0,
|
||||
duplicate_count=0,
|
||||
http_status=200,
|
||||
metrics={"parser": "generic-html", "warnings": ["veld ontbreekt", "datum ontbreekt"]},
|
||||
)
|
||||
assert enforce_source_health()["quarantined"] == 1
|
||||
source.refresh_from_db()
|
||||
assert source.status == Source.Status.QUARANTINED
|
||||
assert source.policy == Source.Policy.DENY
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_collect_source_health_exposes_parser_and_counts(source):
|
||||
_create_run(
|
||||
source,
|
||||
status=SourceRun.Status.SUCCESS,
|
||||
error_category="",
|
||||
extracted_count=2,
|
||||
created_count=1,
|
||||
updated_count=1,
|
||||
duplicate_count=0,
|
||||
metrics={"parser": "jsonld", "warnings": ["warn"]},
|
||||
)
|
||||
row = collect_source_health()[0]
|
||||
assert row.source_id == source.pk
|
||||
assert row.last_parser == "jsonld"
|
||||
assert row.last_parser_warnings == 1
|
||||
assert row.extracted_count == 2
|
||||
assert row.updated_count == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_recover_source_health_starts_canary_once(source, monkeypatch):
|
||||
source.status = Source.Status.QUARANTINED
|
||||
source.policy = Source.Policy.DENY
|
||||
source.metadata = {
|
||||
"source_health": {
|
||||
"state": "quarantined",
|
||||
"quarantine_reason": "tijdelijke fout",
|
||||
"quarantined_at": timezone.now().isoformat(),
|
||||
"recovery_due_at": timezone.now().isoformat(),
|
||||
"canary_started": False,
|
||||
}
|
||||
}
|
||||
source.save(update_fields=["status", "policy", "metadata"])
|
||||
|
||||
calls: list[int] = []
|
||||
|
||||
def fake_delay(source_id: int, force: bool = False):
|
||||
calls.append(source_id)
|
||||
return source_id
|
||||
|
||||
monkeypatch.setattr("apps.sources.tasks.fetch_source.delay", fake_delay)
|
||||
|
||||
result = recover_source_health()
|
||||
assert result["canary_started"] == 1
|
||||
assert calls == [source.pk]
|
||||
|
||||
source.refresh_from_db()
|
||||
assert source.status == Source.Status.TRIAL
|
||||
assert source.policy == Source.Policy.REVIEW
|
||||
|
||||
# Canary is eenmalig, niet continu herstartbaar.
|
||||
source.refresh_from_db()
|
||||
assert recover_source_health()["canary_started"] == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cleanup_retains_quarantined_raw_documents(source):
|
||||
old = RawDocument.objects.create(
|
||||
source=source,
|
||||
kind=RawDocument.Kind.TEXT,
|
||||
content_hash="a" * 64,
|
||||
retain_until=timezone.now() - timedelta(days=1),
|
||||
)
|
||||
keep = RawDocument.objects.create(
|
||||
source=source,
|
||||
kind=RawDocument.Kind.TEXT,
|
||||
content_hash="b" * 64,
|
||||
retain_until=timezone.now() - timedelta(days=1),
|
||||
quarantined=True,
|
||||
)
|
||||
|
||||
result = cleanup_raw_documents()
|
||||
assert result["deleted"] == 1
|
||||
assert RawDocument.objects.filter(pk=old.pk).exists() is False
|
||||
assert RawDocument.objects.filter(pk=keep.pk).exists() is True
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_manual_import_view_prefills_source_url(client, user):
|
||||
client.force_login(user)
|
||||
response = client.get(
|
||||
reverse("sources:manual_import"), {"source_url": "https://jobs.example.org/vacature"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert 'value="https://jobs.example.org/vacature"' in response.content.decode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_manual_import_view_posts_url_and_shows_result(client, user, monkeypatch):
|
||||
def fake_fetch(*_args, **_kwargs):
|
||||
return FetchedDocument(
|
||||
requested_url="https://jobs.example.org/engineering",
|
||||
final_url="https://jobs.example.org/engineering",
|
||||
status_code=200,
|
||||
headers={"content-type": "text/html; charset=utf-8"},
|
||||
content=b"<html><body>vacature</body></html>",
|
||||
)
|
||||
|
||||
def fake_process(_document):
|
||||
return {
|
||||
"extracted": 1,
|
||||
"created": 1,
|
||||
"updated": 0,
|
||||
"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)
|
||||
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("sources:manual_import"),
|
||||
{"source_url": "https://jobs.example.org/engineering"},
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "Importresultaat" in html
|
||||
assert "Herkenbare items: 1" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_manual_import_view_paste_prefers_post_flow_and_does_not_fetch(client, user, monkeypatch):
|
||||
def fail_fetch(*_args, **_kwargs):
|
||||
raise AssertionError("Fetcher should not run for paste imports.")
|
||||
|
||||
def fake_process(_document):
|
||||
return {
|
||||
"extracted": 1,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"duplicates": 1,
|
||||
"parser": "manual-test",
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("apps.sources.services.manual_import.fetch_url", fail_fetch)
|
||||
monkeypatch.setattr("apps.sources.services.manual_import.process_raw_document", fake_process)
|
||||
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("sources:manual_import"),
|
||||
{"pasted_text": "Eerste regel\nTweede regel"},
|
||||
)
|
||||
html = response.content.decode("utf-8")
|
||||
assert response.status_code == 200
|
||||
assert "Importresultaat" in html
|
||||
assert "Modus: paste" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS=1,
|
||||
MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS=120,
|
||||
MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS=60,
|
||||
)
|
||||
def test_manual_import_blocks_after_failed_attempts(client, user, monkeypatch):
|
||||
cache.clear()
|
||||
|
||||
def fail_import(*_args, **_kwargs):
|
||||
raise ManualImportError("Handmatige import faalde in test")
|
||||
|
||||
client.force_login(user)
|
||||
monkeypatch.setattr("apps.sources.views.import_manual_source", fail_import)
|
||||
|
||||
response = client.post(
|
||||
reverse("sources:manual_import"),
|
||||
{"source_url": "https://jobs.example.org/engineering"},
|
||||
)
|
||||
assert "Handmatige import faalde in test" in response.content.decode("utf-8")
|
||||
|
||||
response = client.post(
|
||||
reverse("sources:manual_import"),
|
||||
{"source_url": "https://jobs.example.org/engineering"},
|
||||
)
|
||||
assert "Te veel handmatige importverzoeken" in response.content.decode("utf-8")
|
||||
@@ -0,0 +1,217 @@
|
||||
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, SourcePolicyReview, SourceLease, SourceRun
|
||||
from apps.sources.services.fetcher import RateLimitedError, FetchedDocument
|
||||
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
|
||||
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_today_requires_login(client):
|
||||
response = client.get(reverse("dashboard:today"))
|
||||
assert response.status_code == 302
|
||||
assert reverse("login") in response.url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_today_loads_for_user(client, user, profile):
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("dashboard:today"))
|
||||
assert response.status_code == 200
|
||||
assert "Vandaag" in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=2,
|
||||
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=120,
|
||||
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300,
|
||||
)
|
||||
def test_login_view_rate_limits_failed_attempts(client, user):
|
||||
cache.clear()
|
||||
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
{"username": user.username, "password": "wrong"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
{"username": user.username, "password": "wrong"},
|
||||
)
|
||||
body = response.content.decode("utf-8")
|
||||
assert "Te veel inlogpogingen" in body
|
||||
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
{"username": user.username, "password": "wrong"},
|
||||
)
|
||||
assert "Te veel inlogpogingen" in response.content.decode("utf-8")
|
||||
Reference in New Issue
Block a user