Publish curated VacatureRadar source snapshot
This commit is contained in:
commit
e29774cb7f
338 files changed
+32699
No files matched your search
@@ -0,0 +1,246 @@
|
||||
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_pipeline_status_post_is_persistent_audited_and_user_scoped(client, job, 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-status", kwargs={"pk": application.pk}),
|
||||
{"status": Application.Status.INTERVIEW},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
application.refresh_from_db()
|
||||
assert application.status == Application.Status.INTERVIEW
|
||||
assert ApplicationTimelineEvent.objects.filter(
|
||||
application=application,
|
||||
user=user,
|
||||
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
|
||||
metadata__from=Application.Status.APPLIED,
|
||||
metadata__to=Application.Status.INTERVIEW,
|
||||
).exists()
|
||||
|
||||
other = get_user_model().objects.create_user(username="scoped-other")
|
||||
other_application = Application.objects.create(user=other, job=job)
|
||||
assert (
|
||||
client.post(
|
||||
reverse("jobs:application-status", kwargs={"pk": other_application.pk}),
|
||||
{"status": Application.Status.OFFER},
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
|
||||
@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,248 @@
|
||||
"""Tests voor het herwerkte loginscherm en de bijbehorende authenticatie-flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def test_login_page_renders_stitch_design(client):
|
||||
response = client.get(reverse("login"))
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode("utf-8")
|
||||
assert "<h1>Inloggen</h1>" in body
|
||||
assert 'name="username"' in body and 'type="email"' in body
|
||||
assert "Wachtwoord vergeten?" in body
|
||||
# Local-first: geen externe CDN-assets op het loginscherm.
|
||||
assert "cdn.tailwindcss.com" not in body
|
||||
assert "https://fonts.googleapis.com" not in body
|
||||
assert reverse("password_reset") in body
|
||||
assert 'src="/static/js/login.js?' in body
|
||||
assert "<script>" not in body
|
||||
assert "Persoonlijke intelligence cockpit" in body
|
||||
assert 'alt="VacatureRadar"' in body
|
||||
assert "Ontwikkeld door" in body
|
||||
assert 'aria-label="Ontwikkeld door ITWorx.tech"' in body
|
||||
assert 'src="/static/img/itworx-wordmark.png" alt=""' in body
|
||||
assert 'src="/static/img/itworx-wordmark.png"' in body
|
||||
assert 'href="/static/favicon.svg?' in body
|
||||
assert 'sizes="any"' in body
|
||||
|
||||
|
||||
@override_settings(VACATURERADAR_OWNER_NAME="Eigen merk")
|
||||
def test_login_page_keeps_official_brand_separate_from_owner_setting(client):
|
||||
body = client.get(reverse("login")).content.decode("utf-8")
|
||||
|
||||
assert "Eigen merk" not in body
|
||||
assert 'content="VacatureRadar"' in body
|
||||
|
||||
|
||||
def test_login_with_email(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
{"username": user.email, "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("dashboard:today")
|
||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||
|
||||
|
||||
def test_login_with_username_still_works(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
{"username": user.username, "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||
|
||||
|
||||
def test_login_email_case_insensitive(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
{"username": user.email.upper(), "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||
|
||||
|
||||
def test_login_wrong_password_shows_error(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
{"username": user.email, "password": "verkeerd-wachtwoord"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "klopt niet" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_password_reset_sends_email(client, user):
|
||||
response = client.post(reverse("password_reset"), {"email": user.email})
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("password_reset_done")
|
||||
assert len(mail.outbox) == 1
|
||||
message = mail.outbox[0]
|
||||
assert user.email in message.to
|
||||
assert "wachtwoord" in message.subject.lower()
|
||||
assert "/wachtwoord/herstellen/" in message.body
|
||||
|
||||
|
||||
def test_password_reset_unknown_email_is_silent(client):
|
||||
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
|
||||
assert response.status_code == 302
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
|
||||
def test_demo_button_visible_by_default(client):
|
||||
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
|
||||
|
||||
|
||||
@override_settings(DEMO_MODE_ENABLED=False)
|
||||
def test_demo_login_disabled_redirects(client):
|
||||
response = client.post(reverse("demo-login"))
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("login")
|
||||
assert client.session.get("_auth_user_id") is None
|
||||
|
||||
|
||||
@override_settings(DEMO_MODE_ENABLED=False)
|
||||
def test_demo_button_hidden_when_disabled(client):
|
||||
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
|
||||
|
||||
|
||||
def test_demo_login_logs_in_and_seeds_environment(client):
|
||||
from apps.jobs.models import Application, JobPosting, ScoreRun
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
response = client.post(reverse("demo-login"))
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("dashboard:today")
|
||||
|
||||
user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
|
||||
assert not user.has_usable_password()
|
||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||
|
||||
# Fictieve, gevulde demo-omgeving.
|
||||
profile = SearchProfile.objects.get(user=user, is_active=True)
|
||||
assert JobPosting.objects.count() >= 5
|
||||
assert ScoreRun.objects.filter(profile=profile).count() >= 5
|
||||
assert Application.objects.filter(user=user).exists()
|
||||
|
||||
# Het gevulde dashboard rendert zonder fouten.
|
||||
dashboard = client.get(reverse("dashboard:today"))
|
||||
assert dashboard.status_code == 200
|
||||
dashboard_html = dashboard.content.decode("utf-8")
|
||||
assert "Profiel actief" in dashboard_html
|
||||
assert "Radar actief" not in dashboard_html
|
||||
assert "Nog geen persoonlijke matches" not in dashboard_html
|
||||
|
||||
sources = client.get(reverse("sources:list"))
|
||||
assert sources.status_code == 200
|
||||
assert "minstens één import actief" in sources.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_demo_login_is_idempotent(client, monkeypatch):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import Application, JobPosting
|
||||
from apps.jobs.services import demo_seed
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import MailboxConnection, Source
|
||||
|
||||
# 22:30 UTC is already the following calendar day in Europe/Brussels.
|
||||
# Freezing this boundary prevents UTC `.date()` calls from creeping back in.
|
||||
near_local_midnight = datetime(2026, 9, 2, 22, 30, tzinfo=UTC)
|
||||
monkeypatch.setattr(demo_seed.timezone, "now", lambda: near_local_midnight)
|
||||
expected_local_day = timezone.localdate(near_local_midnight)
|
||||
|
||||
client.post(reverse("demo-login"))
|
||||
first = JobPosting.objects.count()
|
||||
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
|
||||
profile = SearchProfile.objects.get(user=demo_user, is_active=True)
|
||||
job = profile.scores.order_by("-score").first().job
|
||||
job.status = JobPosting.Status.EXPIRED
|
||||
job.last_seen = timezone.now() - timedelta(days=30)
|
||||
job.save(update_fields=["status", "last_seen"])
|
||||
application = Application.objects.filter(user=demo_user).first()
|
||||
application.follow_up_date = timezone.localdate() - timedelta(days=14)
|
||||
application.save(update_fields=["follow_up_date"])
|
||||
|
||||
client.logout()
|
||||
client.post(reverse("demo-login"))
|
||||
assert JobPosting.objects.count() == first
|
||||
job.refresh_from_db()
|
||||
application.refresh_from_db()
|
||||
assert job.status == JobPosting.Status.ACTIVE
|
||||
assert timezone.localdate(job.last_seen) == expected_local_day
|
||||
assert application.follow_up_date > expected_local_day
|
||||
assert profile.scores.count() == 6
|
||||
assert MailboxConnection.objects.filter(user=demo_user, enabled=True).count() == 9
|
||||
assert Source.objects.filter(source_type=Source.Type.EMAIL, failure_count=0).count() >= 9
|
||||
|
||||
|
||||
def test_login_password_field_has_no_mask_placeholder(client):
|
||||
html = client.get(reverse("login")).content.decode("utf-8")
|
||||
assert 'id="id_password"' in html
|
||||
assert 'placeholder="••••••••"' not in html
|
||||
|
||||
|
||||
def test_demo_account_is_read_only_and_session_is_bounded(client):
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
client.post(reverse("demo-login"))
|
||||
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
|
||||
active = SearchProfile.objects.get(user=demo_user, is_active=True)
|
||||
alternative = SearchProfile.objects.create(user=demo_user, name="Alternatief", is_active=False)
|
||||
|
||||
response = client.post(
|
||||
reverse("profiles:activate", args=[alternative.pk]),
|
||||
HTTP_REFERER=reverse("profiles:list"),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("profiles:list")
|
||||
active.refresh_from_db()
|
||||
alternative.refresh_from_db()
|
||||
assert active.is_active is True
|
||||
assert alternative.is_active is False
|
||||
assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS
|
||||
|
||||
|
||||
def test_demo_account_can_still_log_out(client):
|
||||
client.post(reverse("demo-login"))
|
||||
response = client.post(reverse("logout"))
|
||||
assert response.status_code == 302
|
||||
assert client.session.get("_auth_user_id") is None
|
||||
|
||||
|
||||
def test_authenticated_demo_page_disables_mutating_forms(client):
|
||||
client.post(reverse("demo-login"))
|
||||
body = client.get(reverse("dashboard:today")).content.decode("utf-8")
|
||||
assert 'data-demo-read-only="true"' in body
|
||||
assert "Publieke demo · alleen-lezen" in body
|
||||
assert "data-demo-allow" in body
|
||||
|
||||
|
||||
def test_entra_login_disabled_redirects(client):
|
||||
response = client.post(reverse("entra-login"))
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("login")
|
||||
|
||||
|
||||
@override_settings(ENTRA_ID_ENABLED=True, ENTRA_CLIENT_ID="", ENTRA_TENANT_ID="")
|
||||
def test_entra_login_enabled_but_unconfigured_is_handled(client):
|
||||
# Ingeschakeld maar zonder credentials: nette redirect, geen 500.
|
||||
response = client.post(reverse("entra-login"), follow=False)
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("login")
|
||||
|
||||
|
||||
@override_settings(ENTRA_ID_ENABLED=True)
|
||||
def test_entra_button_visible_when_enabled(client):
|
||||
assert reverse("entra-login") in client.get(reverse("login")).content.decode("utf-8")
|
||||
@@ -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,201 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
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, MailboxConnection, RawDocument
|
||||
from apps.sources.services.email_import import ingest_email, message_identity
|
||||
from apps.sources.services.imap_import import poll_imap_mailbox
|
||||
from apps.sources.services.mailbox_connections import encrypt_mailbox_password
|
||||
|
||||
|
||||
@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_vdab_alert_has_provider_provenance_and_replays_idempotently(profile):
|
||||
raw = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
|
||||
|
||||
first = ingest_email(raw)
|
||||
second = ingest_email(raw)
|
||||
|
||||
assert second.pk == first.pk
|
||||
assert first.processed is True
|
||||
assert first.raw_document.metadata["alert_provider"] == "vdab"
|
||||
assert len(first.links) == 2
|
||||
assert all("vdab.be/vindeenjob/vacatures/" in link for link in first.links)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "fixture", "expected_domain"),
|
||||
(
|
||||
(MailboxConnection.Platform.LINKEDIN, "linkedin_job_alert.eml", "linkedin.com"),
|
||||
(MailboxConnection.Platform.ICTJOB, "ictjob_job_alert.eml", "ictjob.be"),
|
||||
(MailboxConnection.Platform.JOBAT, "jobat_job_alert.eml", "jobat.be"),
|
||||
(MailboxConnection.Platform.STEPSTONE, "stepstone_job_alert.eml", "stepstone.be"),
|
||||
(MailboxConnection.Platform.CAREERJET, "careerjet_job_alert.eml", "careerjet.be"),
|
||||
(MailboxConnection.Platform.RANDSTAD, "randstad_job_alert.eml", "randstad.be"),
|
||||
(
|
||||
MailboxConnection.Platform.ROBERTHALF,
|
||||
"roberthalf_job_alert.eml",
|
||||
"roberthalf.com",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_jobboard_mail_import_keeps_platform_provenance_and_rejects_external_links(
|
||||
profile, platform, fixture, expected_domain
|
||||
):
|
||||
raw = (Path("fixtures/emails") / fixture).read_bytes()
|
||||
|
||||
record = ingest_email(raw, platform=platform)
|
||||
|
||||
assert record.processed is True
|
||||
assert len(record.links) == 1
|
||||
assert expected_domain in record.links[0]
|
||||
assert "outside.example.invalid" not in record.links[0]
|
||||
assert record.raw_document.metadata["alert_provider"] == platform
|
||||
assert record.raw_document.source.name == f"{platform.label} vacaturemailbox"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_imap_poll_is_bounded_and_one_bad_message_does_not_abort(profile):
|
||||
valid = Path("fixtures/emails/vdab_job_alert.eml").read_bytes()
|
||||
oversized = b"x" * (len(valid) + 1)
|
||||
|
||||
class FakeImapClient:
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.logged_out = False
|
||||
|
||||
def login(self, user, password):
|
||||
return "OK", []
|
||||
|
||||
def select(self, mailbox, readonly=True):
|
||||
assert readonly is True
|
||||
return "OK", []
|
||||
|
||||
def uid(self, command, *args):
|
||||
if command == "search":
|
||||
return "OK", [b"1 2 3"]
|
||||
uid = args[0]
|
||||
if uid == b"1":
|
||||
return "OK", [(b"RFC822", valid)]
|
||||
if uid == b"2":
|
||||
return "OK", [(b"RFC822", oversized)]
|
||||
return "NO", []
|
||||
|
||||
def logout(self):
|
||||
self.logged_out = True
|
||||
|
||||
clients = []
|
||||
|
||||
def factory(host, port, timeout=None):
|
||||
client = FakeImapClient(host, port, timeout=timeout)
|
||||
clients.append(client)
|
||||
return client
|
||||
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
with override_settings(
|
||||
MAILBOX_CREDENTIAL_KEYS=[key],
|
||||
IMAP_MAX_MESSAGES_PER_POLL=3,
|
||||
IMAP_MAX_MESSAGE_BYTES=len(valid),
|
||||
):
|
||||
connection = MailboxConnection.objects.create(
|
||||
user=profile.user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.CUSTOM,
|
||||
custom_host="imap.example.invalid",
|
||||
username="radar@example.invalid",
|
||||
encrypted_password=encrypt_mailbox_password("test-only"),
|
||||
)
|
||||
first = poll_imap_mailbox(
|
||||
connection, client_factory=factory, host_validator=lambda url: url
|
||||
)
|
||||
second = poll_imap_mailbox(
|
||||
connection, client_factory=factory, host_validator=lambda url: url
|
||||
)
|
||||
|
||||
assert first == {
|
||||
"status": "ok",
|
||||
"imported": 1,
|
||||
"duplicates": 0,
|
||||
"skipped_oversized": 1,
|
||||
"failed": 1,
|
||||
}
|
||||
assert second == {
|
||||
"status": "ok",
|
||||
"imported": 0,
|
||||
"duplicates": 1,
|
||||
"skipped_oversized": 1,
|
||||
"failed": 1,
|
||||
}
|
||||
assert all(client.logged_out for client in clients)
|
||||
record = EmailMessageRecord.objects.get(mailbox_connection=connection)
|
||||
assert record.raw_document.metadata["alert_provider"] == "vdab"
|
||||
assert record.raw_document.metadata["mailbox_connection_id"] == connection.pk
|
||||
|
||||
|
||||
@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,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
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,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.jobs.models import Feedback
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"route_name",
|
||||
["jobs:activity", "jobs:employers", "dashboard:system"],
|
||||
)
|
||||
def test_intelligence_routes_require_login(client, route_name):
|
||||
response = client.get(reverse(route_name))
|
||||
assert response.status_code == 302
|
||||
assert reverse("login") in response.url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_activity_feed_filters_and_scopes_user_events(client, user, job):
|
||||
other = get_user_model().objects.create_user(username="activity-other")
|
||||
Feedback.objects.create(
|
||||
user=other,
|
||||
job=job,
|
||||
action=Feedback.Action.HIDE,
|
||||
reason="private-other-marker",
|
||||
)
|
||||
Feedback.objects.create(
|
||||
user=user,
|
||||
job=job,
|
||||
action=Feedback.Action.INTERESTING,
|
||||
reason="own-event-marker",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("jobs:activity"), {"type": "feedback"})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode("utf-8")
|
||||
assert "own-event-marker" in body
|
||||
assert "private-other-marker" not in body
|
||||
assert response.context["event_page"].paginator.count == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_employer_index_and_detail_use_real_aggregates(client, user, profile, job, source):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("jobs:employers"), {"q": job.employer.name})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.context["selected_employer"].pk == job.employer_id
|
||||
assert response.context["selected_employer"].active_vacancies == 1
|
||||
assert response.context["selected_employer"].relevant_vacancies == 1
|
||||
assert job.original_title in response.content.decode("utf-8")
|
||||
|
||||
detail = client.get(reverse("jobs:employer-detail", kwargs={"pk": job.employer_id}))
|
||||
assert detail.status_code == 200
|
||||
assert detail.context["employer"].pk == job.employer_id
|
||||
assert [item.pk for item in detail.context["recent_jobs"]] == [job.pk]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_automation_center_labels_unmeasured_runtime_honestly(client, user):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("dashboard:system"))
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode("utf-8")
|
||||
assert "Automation center" in body
|
||||
assert "worker niet gemeten" in body or "Eager/lokaal" in body
|
||||
assert "Niet geconfigureerd" in body
|
||||
assert len(response.context["phase_rows"]) == 8
|
||||
@@ -0,0 +1,206 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.sources.models import MailboxConnection
|
||||
from apps.sources.services.mailbox_connections import (
|
||||
claim_mailbox_connection,
|
||||
decrypt_mailbox_password,
|
||||
)
|
||||
from apps.sources.tasks import poll_mailbox, schedule_mailbox_polls
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
payload = {
|
||||
"platform": MailboxConnection.Platform.VDAB,
|
||||
"provider": MailboxConnection.Provider.GMAIL,
|
||||
"custom_host": "",
|
||||
"port": 993,
|
||||
"username": "vdab-alerts@example.invalid",
|
||||
"password": "test-app-password",
|
||||
"mailbox": "INBOX",
|
||||
"poll_interval_minutes": 15,
|
||||
"enabled": "on",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, user, monkeypatch):
|
||||
key = Fernet.generate_key().decode("ascii")
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.views.poll_mailbox.delay",
|
||||
lambda connection_id, force=False: calls.append((connection_id, force)),
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[key]):
|
||||
response = client.post(reverse("sources:mailbox_add"), _payload())
|
||||
assert response.status_code == 302
|
||||
vdab = MailboxConnection.objects.get(user=user, platform="vdab")
|
||||
assert decrypt_mailbox_password(vdab) == "test-app-password"
|
||||
assert "test-app-password" not in vdab.encrypted_password
|
||||
|
||||
response = client.post(
|
||||
reverse("sources:mailbox_add"),
|
||||
_payload(
|
||||
platform=MailboxConnection.Platform.INDEED,
|
||||
provider=MailboxConnection.Provider.OUTLOOK,
|
||||
username="indeed-alerts@example.invalid",
|
||||
password="second-app-password",
|
||||
poll_interval_minutes=30,
|
||||
),
|
||||
)
|
||||
assert response.status_code == 302
|
||||
indeed = MailboxConnection.objects.get(user=user, platform="indeed")
|
||||
assert indeed.encrypted_password == ""
|
||||
|
||||
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
|
||||
assert edit_response.status_code == 200
|
||||
assert b"test-app-password" not in edit_response.content
|
||||
assert vdab.encrypted_password.encode() not in edit_response.content
|
||||
|
||||
original_ciphertext = vdab.encrypted_password
|
||||
response = client.post(
|
||||
reverse("sources:mailbox_edit", args=[vdab.pk]),
|
||||
_payload(
|
||||
username="vdab-updated@example.invalid",
|
||||
password="",
|
||||
poll_interval_minutes=5,
|
||||
),
|
||||
)
|
||||
assert response.status_code == 302
|
||||
vdab.refresh_from_db()
|
||||
assert vdab.username == "vdab-updated@example.invalid"
|
||||
assert vdab.poll_interval_minutes == 5
|
||||
assert vdab.encrypted_password == original_ciphertext
|
||||
assert decrypt_mailbox_password(vdab) == "test-app-password"
|
||||
|
||||
response = client.post(reverse("sources:mailbox_toggle", args=[indeed.pk]))
|
||||
assert response.status_code == 302
|
||||
indeed.refresh_from_db()
|
||||
assert indeed.enabled is False
|
||||
|
||||
response = client.post(reverse("sources:sync_platform_alerts", args=[vdab.pk]))
|
||||
assert response.status_code == 302
|
||||
assert calls == [
|
||||
(vdab.pk, True),
|
||||
(indeed.pk, True),
|
||||
(vdab.pk, True),
|
||||
(vdab.pk, True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_sources_page_offers_all_reviewed_jobboard_mailboxes(client, user):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("sources:list"))
|
||||
|
||||
assert response.status_code == 200
|
||||
for platform in MailboxConnection.Platform:
|
||||
assert f'value="{platform.value}"'.encode() in response.content
|
||||
assert f"{platform.label}-alert instellen".encode() in response.content
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_mailbox_views_are_user_scoped_and_require_encryption_key(client, user, monkeypatch):
|
||||
other = get_user_model().objects.create_user(username="other", password="test-only-password")
|
||||
other_connection = MailboxConnection.objects.create(
|
||||
user=other,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
username="private@example.invalid",
|
||||
encrypted_password="opaque-ciphertext",
|
||||
)
|
||||
client.force_login(user)
|
||||
monkeypatch.setattr("apps.sources.views.poll_mailbox.delay", lambda *args, **kwargs: None)
|
||||
|
||||
assert (
|
||||
client.get(reverse("sources:mailbox_edit", args=[other_connection.pk])).status_code == 404
|
||||
)
|
||||
assert (
|
||||
client.post(reverse("sources:sync_platform_alerts", args=[other_connection.pk])).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.post(reverse("sources:mailbox_toggle", args=[other_connection.pk])).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
with override_settings(MAILBOX_CREDENTIAL_KEYS=[]):
|
||||
response = client.post(reverse("sources:mailbox_add"), _payload(password="visible-once"))
|
||||
assert response.status_code == 200
|
||||
assert MailboxConnection.objects.filter(user=user).count() == 0
|
||||
assert b"visible-once" not in response.content
|
||||
assert b"Credentialopslag is niet geconfigureerd" in response.content
|
||||
|
||||
anonymous_response = Client().post(reverse("sources:mailbox_add"), _payload())
|
||||
assert anonymous_response.status_code == 302
|
||||
csrf_client = Client(enforce_csrf_checks=True)
|
||||
csrf_client.force_login(user)
|
||||
assert csrf_client.post(reverse("sources:mailbox_add"), _payload()).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_scheduler_queues_only_due_active_mailboxes_and_claim_is_exclusive(user, monkeypatch):
|
||||
now = timezone.now()
|
||||
due = MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
username="due@example.invalid",
|
||||
encrypted_password="opaque",
|
||||
next_poll_at=now - timedelta(minutes=1),
|
||||
)
|
||||
MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.INDEED,
|
||||
provider=MailboxConnection.Provider.OUTLOOK,
|
||||
username="later@example.invalid",
|
||||
encrypted_password="opaque",
|
||||
next_poll_at=now + timedelta(minutes=20),
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.tasks.poll_mailbox.delay", lambda connection_id: calls.append(connection_id)
|
||||
)
|
||||
|
||||
assert schedule_mailbox_polls() == {"scheduled": 1}
|
||||
assert calls == [due.pk]
|
||||
assert claim_mailbox_connection(connection_id=due.pk, worker_token="worker-a") is not None
|
||||
assert claim_mailbox_connection(connection_id=due.pk, worker_token="worker-b") is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_mailbox_task_does_not_persist_or_return_secret_on_imap_error(user, monkeypatch):
|
||||
connection = MailboxConnection.objects.create(
|
||||
user=user,
|
||||
platform=MailboxConnection.Platform.VDAB,
|
||||
provider=MailboxConnection.Provider.GMAIL,
|
||||
username="errors@example.invalid",
|
||||
encrypted_password="opaque",
|
||||
next_poll_at=timezone.now() - timedelta(minutes=1),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.sources.tasks.poll_imap_mailbox",
|
||||
lambda connection: (_ for _ in ()).throw(RuntimeError("leaked-app-password")),
|
||||
)
|
||||
|
||||
result = poll_mailbox(connection.pk)
|
||||
connection.refresh_from_db()
|
||||
assert "leaked-app-password" not in str(result)
|
||||
assert "leaked-app-password" not in connection.last_error_message
|
||||
assert result == {"status": "failed", "error_category": "imap", "imported": 0}
|
||||
@@ -0,0 +1,212 @@
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import (
|
||||
Employer,
|
||||
FieldProvenance,
|
||||
JobPosting,
|
||||
JobSourceAlias,
|
||||
JobVersion,
|
||||
ScoreRun,
|
||||
)
|
||||
from apps.jobs.services.pipeline import PersistenceContext, 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
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_unchanged_import_batch_has_bounded_lookup_queries(source, profile):
|
||||
content = Path("fixtures/pages/sample_jsonld_job.html").read_text(encoding="utf-8")
|
||||
documents = [
|
||||
RawDocument.objects.create(
|
||||
source=source,
|
||||
url=f"https://jobs.example.org/import/{index}",
|
||||
final_url=f"https://jobs.example.org/import/{index}",
|
||||
kind=RawDocument.Kind.HTML,
|
||||
content_type="text/html",
|
||||
content_hash=hashlib.sha256(f"{index}{content}".encode()).hexdigest(),
|
||||
body_text=content,
|
||||
byte_length=len(content.encode()),
|
||||
retain_until=timezone.now() + timedelta(days=7),
|
||||
)
|
||||
for index in range(20)
|
||||
]
|
||||
context = PersistenceContext()
|
||||
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
results = [process_raw_document(document, context=context) for document in documents]
|
||||
|
||||
assert sum(int(result["created"]) for result in results) == 1
|
||||
assert sum(int(result["duplicates"]) for result in results) == 19
|
||||
assert len(queries) < 300
|
||||
assert Employer.objects.count() == 1
|
||||
assert JobPosting.objects.count() == 1
|
||||
assert JobVersion.objects.count() == 1
|
||||
assert FieldProvenance.objects.count() > 0
|
||||
assert ScoreRun.objects.filter(profile=profile).count() == 20
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_pipeline_uses_reviewed_employer_identity_for_public_ats_feed(profile):
|
||||
source = Source.objects.create(
|
||||
name="Deliverect",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://api.lever.co/v0/postings/deliverect?mode=json",
|
||||
domain="api.lever.co",
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
metadata={"employer_name": "Deliverect", "direct_employer": True},
|
||||
)
|
||||
content = Path("fixtures/ats/lever-public-postings.json").read_text(encoding="utf-8")
|
||||
document = RawDocument.objects.create(
|
||||
source=source,
|
||||
url=source.base_url,
|
||||
final_url=source.base_url,
|
||||
kind=RawDocument.Kind.JSON,
|
||||
content_type="application/json",
|
||||
content_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||
body_text=content,
|
||||
byte_length=len(content.encode()),
|
||||
retain_until=timezone.now() + timedelta(days=7),
|
||||
)
|
||||
|
||||
result = process_raw_document(document)
|
||||
|
||||
assert result["created"] == 1
|
||||
job = JobPosting.objects.get()
|
||||
assert job.employer_name == "Deliverect"
|
||||
assert job.direct_employer is True
|
||||
evidence = job.source_aliases.get().field_evidence.get(field_name="employer_name")
|
||||
assert evidence.extraction_method == "reviewed-source-config"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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,119 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.jobs.models import GeocodeLocationLookup
|
||||
from apps.profiles.forms import SearchProfileForm
|
||||
from apps.profiles.taxonomy import iter_choices
|
||||
|
||||
|
||||
def _profile_post_data(**overrides):
|
||||
data = {
|
||||
"name": "Testprofiel",
|
||||
"is_active": "on",
|
||||
"home_postal_code": "3500",
|
||||
"max_distance_km": "45",
|
||||
"experience_years": "10",
|
||||
"desired_titles": ["infrastructure engineer", "cloud engineer"],
|
||||
"excluded_titles": ["sales"],
|
||||
"desired_skills": ["azure", "terraform"],
|
||||
"excluded_skills": ["cold calling"],
|
||||
"allowed_employment_types": ["full_time", "permanent"],
|
||||
"preferred_workplace": ["hybrid"],
|
||||
"preferred_regions": ["Limburg"],
|
||||
"excluded_regions": ["West-Vlaanderen"],
|
||||
"recommendation_threshold": "65",
|
||||
"top_match_threshold": "90",
|
||||
"digest_time": "07:30",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_form_uses_guided_choices_and_preserves_existing_custom_values(profile):
|
||||
form = SearchProfileForm(instance=profile)
|
||||
|
||||
assert form.fields["home_postal_code"].widget.attrs["maxlength"] == 4
|
||||
assert "home_municipality" not in form.fields
|
||||
assert "home_latitude" not in form.fields
|
||||
assert "home_longitude" not in form.fields
|
||||
assert form.fields["desired_titles"].widget.allow_multiple_selected is True
|
||||
title_choices = list(iter_choices(form.fields["desired_titles"].choices))
|
||||
skill_choices = list(iter_choices(form.fields["desired_skills"].choices))
|
||||
assert ("it field engineer", "IT Field Engineer") in title_choices
|
||||
assert ("system network engineer", "System & Network Engineer") in title_choices
|
||||
assert ("systeembeheerder", "Systeembeheerder") in title_choices
|
||||
assert ("vmware", "VMware") in skill_choices
|
||||
assert ("microsoft copilot", "Microsoft Copilot") in skill_choices
|
||||
assert ("fortinet", "Fortinet / FortiGate") in skill_choices
|
||||
assert ("itil", "ITIL / IT-servicemanagement") in skill_choices
|
||||
assert form.fields["desired_titles"].widget.attrs["class"] == "grouped-choices"
|
||||
assert form.fields["experience_years"].widget.allow_multiple_selected is False
|
||||
assert "nooit mee" in form.fields["experience_years"].help_text
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_form_rejects_non_belgian_postcode_and_conflicting_choices(profile):
|
||||
invalid_postcode = SearchProfileForm(
|
||||
data=_profile_post_data(home_postal_code="3500 Hasselt"), instance=profile
|
||||
)
|
||||
assert invalid_postcode.is_valid() is False
|
||||
assert "home_postal_code" in invalid_postcode.errors
|
||||
|
||||
conflicting = SearchProfileForm(
|
||||
data=_profile_post_data(preferred_regions=["Limburg"], excluded_regions=["Limburg"]),
|
||||
instance=profile,
|
||||
)
|
||||
assert conflicting.is_valid() is False
|
||||
assert "excluded_regions" in conflicting.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_update_derives_home_location_from_postcode(client, user, profile):
|
||||
GeocodeLocationLookup.objects.create(
|
||||
source_name="test-geodata",
|
||||
source_version="2026-01",
|
||||
query_kind=GeocodeLocationLookup.QueryKind.POSTAL,
|
||||
query_value="3500",
|
||||
postal_code="3500",
|
||||
municipality="Hasselt",
|
||||
region="Limburg",
|
||||
latitude=Decimal("50.930700"),
|
||||
longitude=Decimal("5.332500"),
|
||||
confidence=Decimal("1.00"),
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
reverse("profiles:edit", kwargs={"pk": profile.pk}), _profile_post_data()
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
profile.refresh_from_db()
|
||||
assert profile.home_postal_code == "3500"
|
||||
assert profile.home_municipality == "Hasselt"
|
||||
assert profile.home_latitude == Decimal("50.930700")
|
||||
assert profile.experience_years == 10
|
||||
assert profile.desired_titles == ["infrastructure engineer", "cloud engineer"]
|
||||
assert profile.preferred_workplace == ["hybrid"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_profile_update_clears_stale_coordinates_when_postcode_is_unknown(client, user, profile):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
reverse("profiles:edit", kwargs={"pk": profile.pk}),
|
||||
_profile_post_data(home_postal_code="9999"),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "De postcode is bewaard" in response.content.decode("utf-8")
|
||||
profile.refresh_from_db()
|
||||
assert profile.home_postal_code == "9999"
|
||||
assert profile.home_municipality == ""
|
||||
assert profile.home_latitude is None
|
||||
assert profile.home_longitude is None
|
||||
@@ -0,0 +1,132 @@
|
||||
from datetime import datetime, time
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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,91 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.sources.models import Source, SourcePolicyReview
|
||||
from apps.sources.views import _source_list_queryset
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_seed_sources_registers_reviewed_live_feed_and_hides_demo(tmp_path):
|
||||
Source.objects.create(
|
||||
name="Voorbeeldwerkgever (fixture)",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
base_url="https://jobs.example.org/vacatures/",
|
||||
domain="jobs.example.org",
|
||||
)
|
||||
expires_at = (timezone.now() + timedelta(days=30)).isoformat()
|
||||
retired_source = Source.objects.create(
|
||||
name="Brusselse werkgever",
|
||||
source_type=Source.Type.ATS,
|
||||
base_url="https://brussels-employer.example/jobs",
|
||||
domain="brussels-employer.example",
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
next_run_at=timezone.now(),
|
||||
)
|
||||
payload = {
|
||||
"disable_demo_sources": True,
|
||||
"retire_source_urls": [retired_source.base_url],
|
||||
"sources": [
|
||||
{
|
||||
"name": "VITO",
|
||||
"url": "https://vito.recruitee.com/api/offers/",
|
||||
"type": "ats",
|
||||
"status": "trial",
|
||||
"policy": "review",
|
||||
"parser": "ats-recruitee",
|
||||
"allow_public_endpoint": True,
|
||||
"metadata": {"country_scope": "BE"},
|
||||
"review": {
|
||||
"decision": "trial",
|
||||
"reason": "Publieke feed",
|
||||
"evidence_link": "https://docs.recruitee.com/reference/offers",
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Lever werkgever A",
|
||||
"url": "https://api.lever.co/v0/postings/werkgever-a?mode=json",
|
||||
"type": "ats",
|
||||
"status": "trial",
|
||||
"policy": "allow",
|
||||
"parser": "ats-lever",
|
||||
"allow_public_endpoint": True,
|
||||
},
|
||||
{
|
||||
"name": "Lever werkgever B",
|
||||
"url": "https://api.lever.co/v0/postings/werkgever-b?mode=json",
|
||||
"type": "ats",
|
||||
"status": "trial",
|
||||
"policy": "allow",
|
||||
"parser": "ats-lever",
|
||||
"allow_public_endpoint": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
seed_path = tmp_path / "sources.yaml"
|
||||
seed_path.write_text(yaml.safe_dump(payload), encoding="utf-8")
|
||||
|
||||
call_command("seed_sources", str(seed_path))
|
||||
call_command("seed_sources", str(seed_path))
|
||||
|
||||
demo = Source.objects.get(domain="jobs.example.org")
|
||||
assert demo.status == Source.Status.DISABLED
|
||||
assert demo.metadata["demo"] is True
|
||||
retired_source.refresh_from_db()
|
||||
assert retired_source.status == Source.Status.DISABLED
|
||||
assert retired_source.next_run_at is None
|
||||
assert retired_source.metadata["hidden_from_source_list"] is True
|
||||
assert retired_source.metadata["retired_reason"] == "Buiten de ingestelde regionale dekking"
|
||||
source = Source.objects.get(name="VITO")
|
||||
assert retired_source not in _source_list_queryset()
|
||||
assert source in _source_list_queryset()
|
||||
assert source.allow_public_endpoint is True
|
||||
assert source.parser_key == "ats-recruitee"
|
||||
assert source.latest_policy_review.decision == SourcePolicyReview.Decision.TRIAL
|
||||
assert source.policy_reviews.count() == 1
|
||||
assert Source.objects.filter(domain="api.lever.co", source_type=Source.Type.ATS).count() == 2
|
||||
@@ -0,0 +1,88 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.jobs.models import JobPosting, ScoreRun
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_skill_insights_require_login(client):
|
||||
response = client.get(reverse("jobs:skill-insights"))
|
||||
|
||||
assert response.status_code == 302
|
||||
assert reverse("login") in response.url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_skill_insights_use_current_profile_and_exclude_hidden_jobs(client, user, profile, job):
|
||||
job.skills_required = ["Microsoft 365"]
|
||||
job.description_text = "Microsoft 365, Dynamics 365 en Microsoft Copilot governance."
|
||||
job.save(update_fields=["skills_required", "description_text"])
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=74,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
hidden = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Network Engineer buiten bereik",
|
||||
normalized_title="network engineer buiten bereik",
|
||||
canonical_url="https://jobs.example.org/vacatures/network-hidden",
|
||||
canonical_key="1" * 64,
|
||||
content_hash="2" * 64,
|
||||
description_text="Beheer FortiGate-firewalls.",
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=hidden,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=80,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.HIDDEN,
|
||||
hard_exclusions=["Buiten de zoekradius."],
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("jobs:skill-insights"))
|
||||
body = response.content.decode()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Skillsradar" in body
|
||||
assert "Microsoft 365" in body
|
||||
assert "Microsoft Dynamics 365" in body
|
||||
assert "Microsoft Copilot" in body
|
||||
assert "Fortinet / FortiGate" not in body
|
||||
assert "Ervaringsjaren tellen nergens mee" in body
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_skill_insights_offer_category_and_gap_filters(client, user, profile, job):
|
||||
job.skills_required = ["Microsoft 365", "FortiGate"]
|
||||
job.description_text = "Microsoft 365 en FortiGate-beheer."
|
||||
job.save(update_fields=["skills_required", "description_text"])
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=74,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
reverse("jobs:skill-insights"),
|
||||
{"category": "netwerk-security", "coverage": "gap"},
|
||||
)
|
||||
body = response.content.decode()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Fortinet / FortiGate" in body
|
||||
assert "Microsoft 365" not in body
|
||||
assert "Leerkans" in body
|
||||
@@ -0,0 +1,188 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
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_collect_source_health_prefetches_runs_for_all_sources(source, django_assert_num_queries):
|
||||
second_source = Source.objects.create(
|
||||
name="Second jobs",
|
||||
source_type=Source.Type.EMPLOYER,
|
||||
base_url="https://second.example.org/jobs/",
|
||||
domain="second.example.org",
|
||||
status=Source.Status.ACTIVE,
|
||||
policy=Source.Policy.ALLOW,
|
||||
)
|
||||
_create_run(source, status=SourceRun.Status.SUCCESS)
|
||||
_create_run(second_source, status=SourceRun.Status.SUCCESS)
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
rows = collect_source_health()
|
||||
|
||||
assert [row.source_name for row in rows] == ["Example jobs", "Second jobs"]
|
||||
|
||||
|
||||
@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,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
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,336 @@
|
||||
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
|
||||
@@ -0,0 +1,242 @@
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import JobPosting, ScoreRun
|
||||
|
||||
|
||||
@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_browser_favicon_and_product_brand_assets_are_local(client):
|
||||
response = client.get("/favicon.ico")
|
||||
assert response.status_code == 301
|
||||
assert response.url.endswith("/static/favicon.svg")
|
||||
|
||||
favicon = (settings.BASE_DIR / "static" / "favicon.svg").read_text(encoding="utf-8")
|
||||
product_logo = (settings.BASE_DIR / "static" / "img" / "vacatureradar-logo.svg").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
wordmark = settings.BASE_DIR / "static" / "img" / "itworx-wordmark.png"
|
||||
for signature in ('stroke="#19d3e6"', 'stroke="#7df5ff"', 'fill="#45d69b"'):
|
||||
assert signature in favicon
|
||||
assert 'stroke="#19d3e6"' in product_logo
|
||||
assert 'fill="#36c990"' in product_logo
|
||||
assert "<rect" not in product_logo
|
||||
assert 'viewBox="0 0 64 64"' in favicon
|
||||
assert wordmark.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
||||
assert wordmark.stat().st_size < 100_000
|
||||
|
||||
|
||||
@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()
|
||||
body = response.content.decode()
|
||||
assert 'aria-label="VacatureRadar dashboard"' in body
|
||||
assert "Intelligence cockpit" in body
|
||||
assert "Ontwikkeld door" in body
|
||||
assert 'aria-label="Ontwikkeld door ITWorx.tech"' in body
|
||||
assert 'class="sidebar-footer"' in body
|
||||
assert 'class="sidebar-account"' not in body
|
||||
assert "Dagoverzicht" in body
|
||||
assert 'content="VacatureRadar"' in body
|
||||
|
||||
|
||||
def test_premium_layout_uses_one_bounded_ultrawide_grid():
|
||||
css = (settings.BASE_DIR / "static" / "css" / "premium.css").read_text(encoding="utf-8")
|
||||
assert "--content-frame: 1720px" in css
|
||||
assert "width: min(100%, var(--content-frame))" in css
|
||||
assert "margin-inline: auto" in css
|
||||
assert ".topbar-inner" in css
|
||||
assert "--content-frame: 2200px" in css
|
||||
assert "--content-frame: 2480px" in css
|
||||
assert "@media (min-width: 1800px)" in css
|
||||
assert "minmax(650px, 880px) minmax(760px, 1fr)" in css
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_demo_banner_is_part_of_main_content_flow(client, user, settings):
|
||||
settings.DEMO_MODE_ENABLED = True
|
||||
settings.DEMO_READ_ONLY = True
|
||||
settings.DEMO_USERNAME = user.username
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("dashboard:today"))
|
||||
body = response.content.decode()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert body.index('<main id="main"') < body.index('class="demo-mode-banner"')
|
||||
assert 'role="status" aria-label="Publieke demo, alleen-lezen"' in body
|
||||
|
||||
|
||||
def test_css_imports_share_the_release_cache_version():
|
||||
css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
assert css.count("?v=0.3.13") == 7
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_job_list_defaults_to_ranked_it_focus_but_keeps_explicit_archive(
|
||||
client, user, profile, job
|
||||
):
|
||||
non_it = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Spontane sollicitatie - logopedist",
|
||||
normalized_title="spontane sollicitatie logopedist",
|
||||
canonical_url="https://jobs.example.org/vacatures/logopedist",
|
||||
canonical_key="9" * 64,
|
||||
content_hash="8" * 64,
|
||||
description_text="Zorgfunctie met toegang tot digitale patiëntendossiers.",
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
misleading_it_title = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="IT Recruitment Specialist",
|
||||
normalized_title="it recruitment specialist",
|
||||
canonical_url="https://jobs.example.org/vacatures/it-recruitment",
|
||||
canonical_key="3" * 64,
|
||||
content_hash="2" * 64,
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=60,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.WEAK,
|
||||
positives=["IT-focus bevestigd via de functietitel."],
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=non_it,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=99,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
hidden_it = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Cloud Engineer buiten de zoekradius",
|
||||
normalized_title="cloud engineer buiten de zoekradius",
|
||||
canonical_url="https://jobs.example.org/vacatures/cloud-ver",
|
||||
canonical_key="5" * 64,
|
||||
content_hash="4" * 64,
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=hidden_it,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=98,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.HIDDEN,
|
||||
hard_exclusions=["Buiten de zoekradius."],
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
focused = client.get(reverse("jobs:list"))
|
||||
focused_body = focused.content.decode()
|
||||
assert job.original_title in focused_body
|
||||
assert non_it.original_title not in focused_body
|
||||
assert misleading_it_title.original_title not in focused_body
|
||||
assert hidden_it.original_title not in focused_body
|
||||
assert focused.context["it_job_count"] == 2
|
||||
assert focused.context["filtered_non_it_count"] == 2
|
||||
|
||||
archive = client.get(reverse("jobs:list"), {"focus": "all", "match": "all"})
|
||||
archive_body = archive.content.decode()
|
||||
assert non_it.original_title in archive_body
|
||||
assert misleading_it_title.original_title in archive_body
|
||||
assert hidden_it.original_title in archive_body
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_today_uses_latest_user_score_and_excludes_non_it(client, user, profile, job):
|
||||
non_it = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Technieker Facilitaire Diensten",
|
||||
normalized_title="technieker facilitaire diensten",
|
||||
canonical_url="https://jobs.example.org/vacatures/facilitair",
|
||||
canonical_key="7" * 64,
|
||||
content_hash="6" * 64,
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
first_seen=timezone.now(),
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=70,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=66,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=non_it,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=95,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.STRONG,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("dashboard:today"))
|
||||
|
||||
cards = list(response.context["score_cards"])
|
||||
assert [card.job_id for card in cards] == [job.id]
|
||||
assert cards[0].score == 66
|
||||
assert response.context["filtered_non_it_jobs"] == 1
|
||||
|
||||
|
||||
@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