Putting IMAP settings in environment variables was my call to make and it was the wrong one: it meant editing a file on the server and restarting to change a folder name, and it was never offered as a choice. The mailbox is now a row in mailbox_accounts, edited under Settings → Admin: host, user, password, port, folder, interval, on/off. Administrators only — it is a shared forwarding target, not a per-user account. The password is never returned by any endpoint, the same as the CalDAV account routes, and leaving the field empty on edit keeps the stored one so changing a port does not mean retyping it. The panel shows when the mailbox was last read and what went wrong if it did, because a mailbox that quietly stopped working looks exactly like one that has no mail. Worth stating plainly: the password now lives in the database, in clear text, like the CalDAV, Google and Home Assistant credentials beside it. Unlike those three, an IMAP password opens an entire mailbox. The scheduler job is registered unconditionally now — whether a mailbox exists can change without a restart, and fetch_invites returns immediately when none does. test_scheduler knows about it. Also adds the requests list to the sidebar with accept and decline, 26 translation keys across all ten languages, and tests covering that the password is never returned, that an edit without one keeps it, and that a disabled mailbox counts as unconfigured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
4.3 KiB
Python
122 lines
4.3 KiB
Python
"""The in-process job scheduler and its two jobs."""
|
|
|
|
import time
|
|
|
|
import models
|
|
import scheduler
|
|
from conftest import auth, register_admin
|
|
from database import SessionLocal
|
|
|
|
|
|
def _run(fn):
|
|
db = SessionLocal()
|
|
try:
|
|
fn(db)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_default_jobs_are_registered_with_sane_intervals():
|
|
scheduler.install_default_jobs()
|
|
names = {j.name: j for j in scheduler._jobs}
|
|
assert set(names) == {"attachments.sweep", "ical.refresh", "mail.invites"}
|
|
assert names["attachments.sweep"].interval == 24 * 3600
|
|
assert names["ical.refresh"].interval == 15 * 60
|
|
assert names["mail.invites"].interval == 5 * 60
|
|
# Nothing runs immediately: a restart must not be slowed by maintenance.
|
|
assert all(j.next_run > time.monotonic() for j in scheduler._jobs)
|
|
|
|
|
|
def test_registering_the_same_name_twice_replaces_it():
|
|
scheduler.register("demo", 60, lambda db: None)
|
|
scheduler.register("demo", 120, lambda db: None)
|
|
matching = [j for j in scheduler._jobs if j.name == "demo"]
|
|
assert len(matching) == 1 and matching[0].interval == 120
|
|
scheduler._jobs[:] = [j for j in scheduler._jobs if j.name != "demo"]
|
|
|
|
|
|
def test_a_failing_job_is_contained(caplog):
|
|
"""One broken job must not take down the loop or the other jobs."""
|
|
def boom(db):
|
|
raise RuntimeError("kaputt")
|
|
|
|
job = scheduler._Job("explodes", 60, boom)
|
|
scheduler._run_job(job) # must not raise
|
|
assert "explodes" in caplog.text
|
|
|
|
|
|
def test_sweep_job_cleans_an_orphaned_row(client):
|
|
"""End-to-end through the job function, not just the store helper."""
|
|
token = register_admin(client)
|
|
r = client.post("/api/local/calendars", headers=auth(token),
|
|
json={"name": "Privat", "color": "#4285f4"})
|
|
cal_id = r.json()["id"]
|
|
ev = client.post("/api/local/events", headers=auth(token), json={
|
|
"calendar_id": cal_id, "title": "Termin",
|
|
"start": "2026-06-10T10:00:00+00:00", "end": "2026-06-10T11:00:00+00:00",
|
|
}).json()
|
|
client.post(f"/api/local/events/{ev['id']}/attachments", headers=auth(token),
|
|
files={"file": ("x.pdf", b"%PDF-1.4\ntrailer\n", "application/pdf")})
|
|
|
|
db = SessionLocal()
|
|
ev_row = db.query(models.LocalEvent).filter(models.LocalEvent.uid == ev["id"]).first()
|
|
db.query(models.LocalEvent).filter(models.LocalEvent.id == ev_row.id).delete()
|
|
db.commit()
|
|
db.close()
|
|
|
|
_run(scheduler.sweep_attachments)
|
|
|
|
db = SessionLocal()
|
|
assert db.query(models.EventAttachment).count() == 0
|
|
db.close()
|
|
|
|
|
|
def test_ical_job_survives_an_unreachable_subscription(client):
|
|
"""A subscription with no cached data makes _refresh_if_needed re-raise —
|
|
the job has to wrap each one separately or a single bad URL stops the rest."""
|
|
token = register_admin(client)
|
|
db = SessionLocal()
|
|
user = db.query(models.User).first()
|
|
db.add(models.ICalSubscription(
|
|
user_id=user.id, name="Kaputt",
|
|
url="http://127.0.0.1:9/nicht-erreichbar.ics",
|
|
color="#ea4335", enabled=True, refresh_minutes=60,
|
|
))
|
|
db.commit()
|
|
db.close()
|
|
|
|
_run(scheduler.refresh_ical_subscriptions) # must not raise
|
|
|
|
|
|
def test_the_loop_runs_due_jobs_and_stops_with_the_app(monkeypatch):
|
|
"""Drive the real lifespan with a fast tick and a probe job."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
import main
|
|
|
|
monkeypatch.setattr(scheduler, "TICK_SECONDS", 0.02)
|
|
monkeypatch.setattr(scheduler, "FIRST_RUN_DELAY_SECONDS", 0)
|
|
ran = []
|
|
scheduler.register("probe", 3600, lambda db: ran.append(1))
|
|
try:
|
|
with TestClient(main.app): # entering runs the lifespan
|
|
deadline = time.time() + 3
|
|
while not ran and time.time() < deadline:
|
|
time.sleep(0.02)
|
|
assert ran, "the scheduler loop never ran the registered job"
|
|
finally:
|
|
scheduler._jobs[:] = [j for j in scheduler._jobs if j.name != "probe"]
|
|
|
|
|
|
def test_plain_test_client_starts_no_background_work():
|
|
"""conftest builds TestClient without a context manager, so the lifespan —
|
|
and with it the loop — never starts. Keeps the rest of the suite free of
|
|
background jobs touching the same database."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
import main
|
|
|
|
before = len(scheduler._jobs)
|
|
TestClient(main.app)
|
|
assert len(scheduler._jobs) == before
|