The audit log showed a failed RSS poll as the server's English sentence under a Hungarian UI. A job can now raise progress.JobFailed(reason, message, **params): the scheduler stores the reason and params as the run's data (dashboard, manual-run notice, audit row) beside the English line, and the client renders scheduler.failure.<reason>. The RSS outage names its first channel and the reason code (HTTP 404 vs unreachable). An upstream's own error text stays as raised (diagnostic).
117 lines
5.0 KiB
Python
117 lines
5.0 KiB
Python
"""_claim_running is the single-run guard that both the scheduled fire and the manual trigger go
|
|
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
|
|
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app import scheduler
|
|
from app.models import Notification
|
|
|
|
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
|
|
|
|
|
|
def _release():
|
|
scheduler._record(_JOB, running=False)
|
|
|
|
|
|
def test_claim_is_exclusive_until_released():
|
|
_release()
|
|
# First caller claims it.
|
|
assert scheduler._claim_running(_JOB) is True
|
|
# A second caller is refused while the first still holds it (the TOCTOU the fix closes).
|
|
assert scheduler._claim_running(_JOB) is False
|
|
assert scheduler._claim_running(_JOB) is False
|
|
# _job's finally releases the claim...
|
|
_release()
|
|
# ...and now it can be claimed again.
|
|
assert scheduler._claim_running(_JOB) is True
|
|
_release()
|
|
|
|
|
|
# ---- E30: the result as data -------------------------------------------------------------------
|
|
|
|
|
|
def _snapshot_job(job_id):
|
|
return next(j for j in scheduler.scheduler_snapshot()["jobs"] if j["id"] == job_id)
|
|
|
|
|
|
def test_a_run_keeps_its_result_as_data_for_the_readers_language(db, make_user):
|
|
"""The dashboard and the manual-run notice showed `_summarize`'s English one-liner ("new=3,
|
|
failed=0", a Python repr for a nested phase). The result rides along as data, so the client names
|
|
it; the one-liner stays as the fallback and the audit text."""
|
|
admin = make_user("sched@example.com", role="admin")
|
|
token = scheduler._manual_actor.set(admin.id)
|
|
try:
|
|
scheduler._job("rss_poll", lambda _db: {"new": 3, "failed": 0, "fallback": 1})
|
|
scheduler._job("enrich", lambda _db: 5) # enrich returns a bare count
|
|
finally:
|
|
scheduler._manual_actor.reset(token)
|
|
|
|
rss = _snapshot_job("rss_poll")
|
|
assert rss["result"] == {"new": 3, "failed": 0, "fallback": 1}
|
|
assert rss["last_result"] == "new=3, failed=0, fallback=1"
|
|
assert _snapshot_job("enrich")["result"] == {"enriched": 5}
|
|
|
|
notices = db.execute(
|
|
select(Notification).where(Notification.user_id == admin.id).order_by(Notification.id)
|
|
).scalars()
|
|
assert [n.data["result"] for n in notices] == [
|
|
{"new": 3, "failed": 0, "fallback": 1},
|
|
{"enriched": 5},
|
|
]
|
|
|
|
|
|
def test_a_failed_run_drops_the_previous_result(db):
|
|
scheduler._job("rss_poll", lambda _db: {"new": 1})
|
|
scheduler._job("rss_poll", lambda _db: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
rss = _snapshot_job("rss_poll")
|
|
assert rss["last_error"] == "boom"
|
|
assert rss["result"] is None
|
|
|
|
|
|
def test_a_job_runs_audit_row_carries_its_result_as_data(db):
|
|
"""E30 review: the audit log names a run's result in the reader's language, as the inbox does —
|
|
from data, not from the English one-liner (a nested phase there was a Python dict repr)."""
|
|
from app.models import AuditLog, User
|
|
|
|
admin = User(email="auditrun@example.com")
|
|
db.add(admin)
|
|
db.commit()
|
|
scheduler._audit_run(db, None, "people_enrich", "ok", "x", {"bios": {"enriched": 2}})
|
|
scheduler._audit_run(db, admin.id, "rss_poll", "skipped", "sync paused", scheduler._PAUSED)
|
|
scheduler._audit_run(db, admin.id, "rss_poll", "error", "boom", None)
|
|
rows = db.execute(select(AuditLog).order_by(AuditLog.id)).scalars().all()
|
|
assert [r.after for r in rows] == [
|
|
{"status": "ok", "summary": "x", "result": {"bios": {"enriched": 2}}},
|
|
{"status": "skipped", "summary": "sync paused", "result": {"skipped": "sync paused"}},
|
|
{"status": "error", "summary": "boom"},
|
|
]
|
|
|
|
|
|
def test_a_failure_the_job_names_rides_along_as_data(db, make_user):
|
|
"""E31: a failed RSS poll reached the Hungarian audit log as the server's English sentence. A
|
|
failure the job names (`progress.JobFailed`) is stored as data beside that sentence — on the
|
|
dashboard, the manual-run notice and the audit row — so the client says it in the reader's
|
|
language. Any other failure keeps only its own text (the test above)."""
|
|
from app import progress
|
|
from app.models import AuditLog
|
|
|
|
def outage(_db):
|
|
raise progress.JobFailed("rss_all_failed", "RSS poll failed", total=2, cause="transport")
|
|
|
|
admin = make_user("failure@example.com", role="admin")
|
|
token = scheduler._manual_actor.set(admin.id)
|
|
try:
|
|
scheduler._job("rss_poll", outage)
|
|
finally:
|
|
scheduler._manual_actor.reset(token)
|
|
|
|
named = {"failure": "rss_all_failed", "total": 2, "cause": "transport"}
|
|
rss = _snapshot_job("rss_poll")
|
|
assert rss["last_error"] == "RSS poll failed"
|
|
assert rss["result"] == named
|
|
notice = db.execute(select(Notification).where(Notification.user_id == admin.id)).scalar_one()
|
|
assert notice.data["result"] == named
|
|
row = db.execute(select(AuditLog).order_by(AuditLog.id.desc())).scalars().first()
|
|
assert row is not None and row.after["result"] == named
|