Files
ping-mee-self-service-platform/portal/tests/test_scheduler.py
T
ebbe.bassandClaude Sonnet 5.5 20510a73e5 feat: add scheduler and mail outbox
SMTP adapter with TLS and a fake, outbox with retries, backoff, rate limit and restart safety, scheduler tick with job-run records started by gunicorn, and a send_test_mail command.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
2026-10-18 09:18:42 +02:00

105 lines
3.2 KiB
Python

"""The scheduler: one tick runs the jobs and records each run (AD-12)."""
import threading
from datetime import timedelta
import pytest
from django.utils import timezone
from portal.mailer import services as mailer
from portal.scheduler import services
from portal.scheduler.models import JobRun
from portal.scheduler.runner import Runner
pytestmark = pytest.mark.django_db(transaction=True)
def test_a_tick_runs_the_mail_job_and_records_the_run(adapters):
mailer.enqueue("test", "lena@example.test")
services.tick()
assert len(adapters.mailer.sent) == 1
run = JobRun.objects.get(job="mail.flush")
assert run.status == JobRun.Status.OK and run.finished_at
assert run.detail == "sent=1 retried=0 failed=0"
def test_an_idle_tick_is_recorded_too_and_repeating_it_is_harmless(adapters):
services.tick()
services.tick()
assert JobRun.objects.filter(job="mail.flush").count() == 2
assert adapters.mailer.sent == []
def test_a_restart_sends_nothing_twice(adapters):
mailer.enqueue("test", "lena@example.test")
services.tick()
services.tick(timezone.now() + timedelta(minutes=5)) # "after a restart"
assert len(adapters.mailer.sent) == 1
def test_a_failing_job_is_recorded_and_the_others_still_run():
ran = []
def broken(now):
raise RuntimeError("secret detail that must not be stored")
jobs = (
services.Job("first", lambda now: ran.append("first") or "fine"),
services.Job("broken", broken),
services.Job("last", lambda now: ran.append("last") or "also fine"),
)
services.tick(jobs=jobs)
assert ran == ["first", "last"]
broken_run = JobRun.objects.get(job="broken")
assert broken_run.status == JobRun.Status.ERROR and broken_run.detail == "RuntimeError"
assert JobRun.objects.get(job="last").status == JobRun.Status.OK
def test_old_run_records_are_removed():
now = timezone.now()
JobRun.objects.create(job="x", started_at=now - timedelta(days=services.KEEP_RUNS_DAYS + 1))
JobRun.objects.create(job="x", started_at=now - timedelta(days=1))
services.tick(now, jobs=())
assert JobRun.objects.filter(job="x").count() == 1
# -- the thread -----------------------------------------
def test_the_thread_ticks_repeatedly_and_stops():
ticked = threading.Semaphore(0)
runner = Runner(tick=ticked.release, interval=0.01)
runner.start()
assert ticked.acquire(timeout=2) and ticked.acquire(timeout=2)
runner.stop()
assert not runner._thread.is_alive()
def test_a_failing_tick_does_not_stop_the_thread():
calls = threading.Semaphore(0)
def tick():
calls.release()
raise RuntimeError("boom")
runner = Runner(tick=tick, interval=0.01)
runner.start()
assert calls.acquire(timeout=2) and calls.acquire(timeout=2)
runner.stop()
def test_starting_twice_runs_only_one_thread():
runner = Runner(tick=lambda: None, interval=0.05)
runner.start()
first = runner._thread
runner.start()
assert runner._thread is first
runner.stop()
assert sum(1 for t in threading.enumerate() if t.name == "scheduler") == 0
def test_the_default_interval_is_thirty_seconds():
from portal.scheduler.runner import TICK_SECONDS, runner
assert TICK_SECONDS == 30 and runner._interval == 30