Publish curated VacatureRadar source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:19:10 +02:00
commit e29774cb7f
338 files changed
+32699

No files matched your search

+246
View File
@@ -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
+248
View File
@@ -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
+108
View File
@@ -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}
+212
View File
@@ -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
+132
View File
@@ -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"
+91
View File
@@ -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
+88
View File
@@ -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
+188
View File
@@ -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
+242
View File
@@ -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")