Initial deploy setup
deploy / deploy (push) Canceled after 0s

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