213 lines
7.6 KiB
Python
213 lines
7.6 KiB
Python
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
|