202 lines
7.1 KiB
Python
202 lines
7.1 KiB
Python
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
|