Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""Behavioral coverage for terminal cron jobs and explicit one-shot re-arm."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import copy
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from cron.jobs import (
|
||||
advance_next_run,
|
||||
advance_next_runs,
|
||||
claim_job_for_fire,
|
||||
create_job,
|
||||
get_due_jobs,
|
||||
get_job,
|
||||
load_jobs,
|
||||
mark_job_run,
|
||||
rearm_oneshot,
|
||||
resume_job,
|
||||
save_jobs,
|
||||
trigger_job,
|
||||
update_job,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_cron_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
|
||||
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
||||
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_completed_oneshot_trigger_is_refused_and_disk_record_is_unchanged(tmp_cron_dir):
|
||||
job = create_job("done", "in 30m", name="done", repeat=1)
|
||||
mark_job_run(job["id"], success=True)
|
||||
before = copy.deepcopy(load_jobs())
|
||||
|
||||
with pytest.raises(ValueError, match="terminal"):
|
||||
trigger_job(job["id"])
|
||||
|
||||
assert load_jobs() == before
|
||||
assert get_job(job["id"]) == before[0]
|
||||
|
||||
|
||||
def test_exhausted_recurring_job_trigger_is_refused(tmp_cron_dir):
|
||||
job = create_job("done", "every 1h", repeat=1)
|
||||
mark_job_run(job["id"], success=True)
|
||||
|
||||
with pytest.raises(ValueError, match="terminal"):
|
||||
trigger_job(job["id"])
|
||||
|
||||
|
||||
def test_wedged_claimed_oneshot_remains_triggerable(tmp_cron_dir):
|
||||
now = datetime.now(timezone.utc)
|
||||
job = create_job("wedged", "in 30m", repeat=2)
|
||||
record = get_job(job["id"])
|
||||
record.update({
|
||||
"run_claim": {"at": now.isoformat(), "by": "dead-worker"},
|
||||
"state": "scheduled",
|
||||
"enabled": True,
|
||||
"next_run_at": (now - timedelta(minutes=1)).isoformat(),
|
||||
})
|
||||
save_jobs([record])
|
||||
|
||||
triggered = trigger_job(job["id"])
|
||||
assert triggered["state"] == "scheduled"
|
||||
assert triggered["enabled"] is True
|
||||
|
||||
|
||||
def test_paused_job_run_override_remains_allowed(tmp_cron_dir):
|
||||
job = create_job("paused", "every 1h")
|
||||
from cron.jobs import pause_job
|
||||
|
||||
pause_job(job["id"])
|
||||
triggered = trigger_job(job["id"])
|
||||
assert triggered["state"] == "scheduled"
|
||||
assert triggered["enabled"] is True
|
||||
|
||||
|
||||
def test_terminal_jobs_are_not_due_or_advanced(tmp_cron_dir):
|
||||
job = create_job("done", "every 1h", repeat=1)
|
||||
mark_job_run(job["id"], success=True)
|
||||
before = copy.deepcopy(load_jobs())
|
||||
|
||||
assert get_due_jobs() == []
|
||||
assert advance_next_run(job["id"]) is False
|
||||
assert load_jobs() == before
|
||||
|
||||
|
||||
def test_terminal_refusal_survives_reload(tmp_cron_dir):
|
||||
job = create_job("done", "in 30m", repeat=1)
|
||||
mark_job_run(job["id"], success=True)
|
||||
before = copy.deepcopy(load_jobs())
|
||||
assert get_job(job["id"])["state"] == "completed"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
trigger_job(job["id"])
|
||||
assert load_jobs() == before
|
||||
|
||||
|
||||
def test_update_cannot_reactivate_terminal_record(tmp_cron_dir):
|
||||
job = create_job("done", "in 30m", repeat=1)
|
||||
mark_job_run(job["id"], success=True)
|
||||
with pytest.raises(ValueError, match="terminal"):
|
||||
update_job(job["id"], {"enabled": True})
|
||||
with pytest.raises(ValueError, match="terminal"):
|
||||
update_job(job["id"], {"schedule": "every 1h"})
|
||||
|
||||
|
||||
def test_rearm_completed_oneshot_restores_schedule_and_preserves_history(tmp_cron_dir):
|
||||
from cron.jobs import rearm_oneshot
|
||||
|
||||
job = create_job("done", "in 30m", repeat=3)
|
||||
mark_job_run(job["id"], success=True)
|
||||
finished = get_job(job["id"])
|
||||
run_at = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat()
|
||||
|
||||
rearmed = rearm_oneshot(job["id"], run_at)
|
||||
assert rearmed["schedule"]["kind"] == "once"
|
||||
assert rearmed["repeat"]["times"] == 3
|
||||
assert rearmed["repeat"]["completed"] == 0
|
||||
assert rearmed["state"] == "scheduled"
|
||||
assert rearmed["enabled"] is True
|
||||
assert rearmed["next_run_at"] == rearmed["schedule"]["run_at"]
|
||||
assert rearmed["last_run_at"] == finished["last_run_at"]
|
||||
assert rearmed["last_status"] == finished["last_status"]
|
||||
|
||||
|
||||
def test_rearm_refuses_recurring_and_live_claim(tmp_cron_dir):
|
||||
from cron.jobs import rearm_oneshot
|
||||
|
||||
recurring = create_job("recurring", "every 1h")
|
||||
future = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat()
|
||||
with pytest.raises(ValueError, match="one-shot"):
|
||||
rearm_oneshot(recurring["id"], future)
|
||||
|
||||
oneshot = create_job("claimed", "in 30m")
|
||||
record = get_job(oneshot["id"])
|
||||
record["run_claim"] = {"at": datetime.now(timezone.utc).isoformat(), "by": "live"}
|
||||
save_jobs([record])
|
||||
with pytest.raises(ValueError, match="claim"):
|
||||
rearm_oneshot(oneshot["id"], future)
|
||||
|
||||
|
||||
class TestRecurringJobStuckInErrorStateIsRecoverable:
|
||||
"""A recurring (cron/interval) job that could not compute its next
|
||||
occurrence is marked ``state=error`` but left ``enabled=True`` — issue
|
||||
#16265's invariant that recurring jobs must never be silently disabled.
|
||||
|
||||
``is_terminal_job()`` previously treated ``state=error`` identically to
|
||||
``state=completed`` at every call site, which blocked BOTH the due-scan's
|
||||
own ``next_run_at`` self-heal AND every manual recovery path
|
||||
(``resume_job``, ``claim_job_for_fire``, ``advance_next_runs``) — wedging
|
||||
the job forever with no exit except deleting and recreating it. These
|
||||
tests pin the fix: an error-state recurring job stays recoverable through
|
||||
every one of those paths, while a genuinely terminal ``state=completed``
|
||||
job (covered by the tests above) remains blocked through all of them.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _force_error_state(job_id):
|
||||
"""Reproduce the exact state _mark_job_run_locked produces when
|
||||
compute_next_run() fails for a recurring job (e.g. croniter
|
||||
missing): state=error, enabled stays True, next_run_at=None."""
|
||||
with mock.patch("cron.jobs.compute_next_run", return_value=None):
|
||||
mark_job_run(job_id, success=True)
|
||||
|
||||
def test_due_scan_self_heals_next_run_at(self, tmp_cron_dir):
|
||||
job = create_job("recurring", "every 5m")
|
||||
self._force_error_state(job["id"])
|
||||
stuck = get_job(job["id"])
|
||||
assert stuck["state"] == "error"
|
||||
assert stuck["enabled"] is True
|
||||
assert stuck["next_run_at"] is None
|
||||
|
||||
# compute_next_run works again on the next tick (the transient
|
||||
# issue resolved) — the due-scan must recompute next_run_at even
|
||||
# though the persisted record is still state=error.
|
||||
get_due_jobs()
|
||||
|
||||
healed = get_job(job["id"])
|
||||
assert healed["next_run_at"] is not None, (
|
||||
"due-scan self-heal never reached an error-state recurring job"
|
||||
)
|
||||
|
||||
def test_resume_job_recovers_error_state(self, tmp_cron_dir):
|
||||
job = create_job("recurring", "every 5m")
|
||||
self._force_error_state(job["id"])
|
||||
|
||||
resumed = resume_job(job["id"])
|
||||
|
||||
assert resumed is not None, "resume_job must not raise on state=error"
|
||||
assert resumed["state"] == "scheduled"
|
||||
assert resumed["enabled"] is True
|
||||
assert resumed["next_run_at"] is not None
|
||||
|
||||
def test_claim_job_for_fire_recovers_error_state(self, tmp_cron_dir):
|
||||
job = create_job("recurring", "every 5m")
|
||||
self._force_error_state(job["id"])
|
||||
|
||||
claimed = claim_job_for_fire(job["id"], return_job=True)
|
||||
|
||||
assert isinstance(claimed, dict), (
|
||||
"claim_job_for_fire must not refuse an error-state recurring "
|
||||
"job — it still has future occurrences"
|
||||
)
|
||||
assert claimed["fire_claim"] is not None
|
||||
|
||||
def test_advance_next_runs_recovers_error_state(self, tmp_cron_dir):
|
||||
job = create_job("recurring", "every 5m")
|
||||
self._force_error_state(job["id"])
|
||||
|
||||
advanced = advance_next_runs([job["id"]])
|
||||
|
||||
assert advanced == 1, (
|
||||
"advance_next_runs must still pre-advance an error-state "
|
||||
"recurring job's next_run_at before it fires (crash-safety)"
|
||||
)
|
||||
|
||||
def test_pause_job_now_works_on_error_state(self, tmp_cron_dir):
|
||||
from cron.jobs import pause_job
|
||||
|
||||
job = create_job("recurring", "every 5m")
|
||||
self._force_error_state(job["id"])
|
||||
|
||||
paused = pause_job(job["id"])
|
||||
|
||||
assert paused is not None, "pause_job must not raise on state=error"
|
||||
assert paused["state"] == "paused"
|
||||
assert paused["enabled"] is False
|
||||
|
||||
def test_completed_oneshot_still_blocked_on_every_path(self, tmp_cron_dir):
|
||||
"""Control: the fix must not weaken protection for a genuinely
|
||||
terminal state=completed job — only state=error recurring jobs are
|
||||
exempted."""
|
||||
job = create_job("done", "in 30m", repeat=1)
|
||||
mark_job_run(job["id"], success=True)
|
||||
assert get_job(job["id"])["state"] == "completed"
|
||||
|
||||
with pytest.raises(ValueError, match="terminal"):
|
||||
resume_job(job["id"])
|
||||
assert claim_job_for_fire(job["id"], return_job=True) is False
|
||||
assert advance_next_runs([job["id"]]) == 0
|
||||
assert get_due_jobs() == []
|
||||
Reference in New Issue
Block a user