Authored by areu01or00. Adds timezone support via hermes_time.now() helper with IANA timezone resolution (HERMES_TIMEZONE env → config.yaml → server-local). Updates system prompt timestamp, cron scheduling, and execute_code sandbox TZ injection. Includes config migration (v4→v5) and comprehensive test coverage.
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""Tests for cron/jobs.py — schedule parsing, job CRUD, and due-job detection."""
|
|
|
|
import json
|
|
import pytest
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from cron.jobs import (
|
|
parse_duration,
|
|
parse_schedule,
|
|
compute_next_run,
|
|
create_job,
|
|
load_jobs,
|
|
save_jobs,
|
|
get_job,
|
|
list_jobs,
|
|
update_job,
|
|
remove_job,
|
|
mark_job_run,
|
|
get_due_jobs,
|
|
save_job_output,
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# parse_duration
|
|
# =========================================================================
|
|
|
|
class TestParseDuration:
|
|
def test_minutes(self):
|
|
assert parse_duration("30m") == 30
|
|
assert parse_duration("1min") == 1
|
|
assert parse_duration("5mins") == 5
|
|
assert parse_duration("10minute") == 10
|
|
assert parse_duration("120minutes") == 120
|
|
|
|
def test_hours(self):
|
|
assert parse_duration("2h") == 120
|
|
assert parse_duration("1hr") == 60
|
|
assert parse_duration("3hrs") == 180
|
|
assert parse_duration("1hour") == 60
|
|
assert parse_duration("24hours") == 1440
|
|
|
|
def test_days(self):
|
|
assert parse_duration("1d") == 1440
|
|
assert parse_duration("7day") == 7 * 1440
|
|
assert parse_duration("2days") == 2 * 1440
|
|
|
|
def test_whitespace_tolerance(self):
|
|
assert parse_duration(" 30m ") == 30
|
|
assert parse_duration("2 h") == 120
|
|
|
|
def test_invalid_raises(self):
|
|
with pytest.raises(ValueError):
|
|
parse_duration("abc")
|
|
with pytest.raises(ValueError):
|
|
parse_duration("30x")
|
|
with pytest.raises(ValueError):
|
|
parse_duration("")
|
|
with pytest.raises(ValueError):
|
|
parse_duration("m30")
|
|
|
|
|
|
# =========================================================================
|
|
# parse_schedule
|
|
# =========================================================================
|
|
|
|
class TestParseSchedule:
|
|
def test_duration_becomes_once(self):
|
|
result = parse_schedule("30m")
|
|
assert result["kind"] == "once"
|
|
assert "run_at" in result
|
|
# run_at should be a valid ISO timestamp string ~30 minutes from now
|
|
run_at_str = result["run_at"]
|
|
assert isinstance(run_at_str, str)
|
|
run_at = datetime.fromisoformat(run_at_str)
|
|
now = datetime.now().astimezone()
|
|
assert run_at > now
|
|
assert run_at < now + timedelta(minutes=31)
|
|
|
|
def test_every_becomes_interval(self):
|
|
result = parse_schedule("every 2h")
|
|
assert result["kind"] == "interval"
|
|
assert result["minutes"] == 120
|
|
|
|
def test_every_case_insensitive(self):
|
|
result = parse_schedule("Every 30m")
|
|
assert result["kind"] == "interval"
|
|
assert result["minutes"] == 30
|
|
|
|
def test_cron_expression(self):
|
|
pytest.importorskip("croniter")
|
|
result = parse_schedule("0 9 * * *")
|
|
assert result["kind"] == "cron"
|
|
assert result["expr"] == "0 9 * * *"
|
|
|
|
def test_iso_timestamp(self):
|
|
result = parse_schedule("2030-01-15T14:00:00")
|
|
assert result["kind"] == "once"
|
|
assert "2030-01-15" in result["run_at"]
|
|
|
|
def test_invalid_schedule_raises(self):
|
|
with pytest.raises(ValueError):
|
|
parse_schedule("not_a_schedule")
|
|
|
|
def test_invalid_cron_raises(self):
|
|
pytest.importorskip("croniter")
|
|
with pytest.raises(ValueError):
|
|
parse_schedule("99 99 99 99 99")
|
|
|
|
|
|
# =========================================================================
|
|
# compute_next_run
|
|
# =========================================================================
|
|
|
|
class TestComputeNextRun:
|
|
def test_once_future_returns_time(self):
|
|
future = (datetime.now() + timedelta(hours=1)).isoformat()
|
|
schedule = {"kind": "once", "run_at": future}
|
|
assert compute_next_run(schedule) == future
|
|
|
|
def test_once_past_returns_none(self):
|
|
past = (datetime.now() - timedelta(hours=1)).isoformat()
|
|
schedule = {"kind": "once", "run_at": past}
|
|
assert compute_next_run(schedule) is None
|
|
|
|
def test_interval_first_run(self):
|
|
schedule = {"kind": "interval", "minutes": 60}
|
|
result = compute_next_run(schedule)
|
|
next_dt = datetime.fromisoformat(result)
|
|
# Should be ~60 minutes from now
|
|
assert next_dt > datetime.now().astimezone() + timedelta(minutes=59)
|
|
|
|
def test_interval_subsequent_run(self):
|
|
schedule = {"kind": "interval", "minutes": 30}
|
|
last = datetime.now().astimezone().isoformat()
|
|
result = compute_next_run(schedule, last_run_at=last)
|
|
next_dt = datetime.fromisoformat(result)
|
|
# Should be ~30 minutes from last run
|
|
assert next_dt > datetime.now().astimezone() + timedelta(minutes=29)
|
|
|
|
def test_cron_returns_future(self):
|
|
pytest.importorskip("croniter")
|
|
schedule = {"kind": "cron", "expr": "* * * * *"} # every minute
|
|
result = compute_next_run(schedule)
|
|
assert isinstance(result, str), f"Expected ISO timestamp string, got {type(result)}"
|
|
assert len(result) > 0
|
|
next_dt = datetime.fromisoformat(result)
|
|
assert isinstance(next_dt, datetime)
|
|
assert next_dt > datetime.now().astimezone()
|
|
|
|
def test_unknown_kind_returns_none(self):
|
|
assert compute_next_run({"kind": "unknown"}) is None
|
|
|
|
|
|
# =========================================================================
|
|
# Job CRUD (with tmp file storage)
|
|
# =========================================================================
|
|
|
|
@pytest.fixture()
|
|
def tmp_cron_dir(tmp_path, monkeypatch):
|
|
"""Redirect cron storage to a temp directory."""
|
|
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
|
|
|
|
|
|
class TestJobCRUD:
|
|
def test_create_and_get(self, tmp_cron_dir):
|
|
job = create_job(prompt="Check server status", schedule="30m")
|
|
assert job["id"]
|
|
assert job["prompt"] == "Check server status"
|
|
assert job["enabled"] is True
|
|
assert job["schedule"]["kind"] == "once"
|
|
|
|
fetched = get_job(job["id"])
|
|
assert fetched is not None
|
|
assert fetched["prompt"] == "Check server status"
|
|
|
|
def test_list_jobs(self, tmp_cron_dir):
|
|
create_job(prompt="Job 1", schedule="every 1h")
|
|
create_job(prompt="Job 2", schedule="every 2h")
|
|
jobs = list_jobs()
|
|
assert len(jobs) == 2
|
|
|
|
def test_remove_job(self, tmp_cron_dir):
|
|
job = create_job(prompt="Temp job", schedule="30m")
|
|
assert remove_job(job["id"]) is True
|
|
assert get_job(job["id"]) is None
|
|
|
|
def test_remove_nonexistent_returns_false(self, tmp_cron_dir):
|
|
assert remove_job("nonexistent") is False
|
|
|
|
def test_auto_repeat_for_once(self, tmp_cron_dir):
|
|
job = create_job(prompt="One-shot", schedule="1h")
|
|
assert job["repeat"]["times"] == 1
|
|
|
|
def test_interval_no_auto_repeat(self, tmp_cron_dir):
|
|
job = create_job(prompt="Recurring", schedule="every 1h")
|
|
assert job["repeat"]["times"] is None
|
|
|
|
def test_default_delivery_origin(self, tmp_cron_dir):
|
|
job = create_job(
|
|
prompt="Test", schedule="30m",
|
|
origin={"platform": "telegram", "chat_id": "123"},
|
|
)
|
|
assert job["deliver"] == "origin"
|
|
|
|
def test_default_delivery_local_no_origin(self, tmp_cron_dir):
|
|
job = create_job(prompt="Test", schedule="30m")
|
|
assert job["deliver"] == "local"
|
|
|
|
|
|
class TestUpdateJob:
|
|
def test_update_name(self, tmp_cron_dir):
|
|
job = create_job(prompt="Check server status", schedule="every 1h", name="Old Name")
|
|
assert job["name"] == "Old Name"
|
|
updated = update_job(job["id"], {"name": "New Name"})
|
|
assert updated is not None
|
|
assert isinstance(updated, dict)
|
|
assert updated["name"] == "New Name"
|
|
# Verify other fields are preserved
|
|
assert updated["prompt"] == "Check server status"
|
|
assert updated["id"] == job["id"]
|
|
assert updated["schedule"] == job["schedule"]
|
|
# Verify persisted to disk
|
|
fetched = get_job(job["id"])
|
|
assert fetched["name"] == "New Name"
|
|
|
|
def test_update_schedule(self, tmp_cron_dir):
|
|
job = create_job(prompt="Daily report", schedule="every 1h")
|
|
assert job["schedule"]["kind"] == "interval"
|
|
assert job["schedule"]["minutes"] == 60
|
|
new_schedule = parse_schedule("every 2h")
|
|
updated = update_job(job["id"], {"schedule": new_schedule})
|
|
assert updated is not None
|
|
assert updated["schedule"]["kind"] == "interval"
|
|
assert updated["schedule"]["minutes"] == 120
|
|
# Verify persisted to disk
|
|
fetched = get_job(job["id"])
|
|
assert fetched["schedule"]["minutes"] == 120
|
|
|
|
def test_update_enable_disable(self, tmp_cron_dir):
|
|
job = create_job(prompt="Toggle me", schedule="every 1h")
|
|
assert job["enabled"] is True
|
|
updated = update_job(job["id"], {"enabled": False})
|
|
assert updated["enabled"] is False
|
|
fetched = get_job(job["id"])
|
|
assert fetched["enabled"] is False
|
|
|
|
def test_update_nonexistent_returns_none(self, tmp_cron_dir):
|
|
result = update_job("nonexistent_id", {"name": "X"})
|
|
assert result is None
|
|
|
|
|
|
class TestMarkJobRun:
|
|
def test_increments_completed(self, tmp_cron_dir):
|
|
job = create_job(prompt="Test", schedule="every 1h")
|
|
mark_job_run(job["id"], success=True)
|
|
updated = get_job(job["id"])
|
|
assert updated["repeat"]["completed"] == 1
|
|
assert updated["last_status"] == "ok"
|
|
|
|
def test_repeat_limit_removes_job(self, tmp_cron_dir):
|
|
job = create_job(prompt="Once", schedule="30m", repeat=1)
|
|
mark_job_run(job["id"], success=True)
|
|
# Job should be removed after hitting repeat limit
|
|
assert get_job(job["id"]) is None
|
|
|
|
def test_error_status(self, tmp_cron_dir):
|
|
job = create_job(prompt="Fail", schedule="every 1h")
|
|
mark_job_run(job["id"], success=False, error="timeout")
|
|
updated = get_job(job["id"])
|
|
assert updated["last_status"] == "error"
|
|
assert updated["last_error"] == "timeout"
|
|
|
|
|
|
class TestGetDueJobs:
|
|
def test_past_due_returned(self, tmp_cron_dir):
|
|
job = create_job(prompt="Due now", schedule="every 1h")
|
|
# Force next_run_at to the past
|
|
jobs = load_jobs()
|
|
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=5)).isoformat()
|
|
save_jobs(jobs)
|
|
|
|
due = get_due_jobs()
|
|
assert len(due) == 1
|
|
assert due[0]["id"] == job["id"]
|
|
|
|
def test_future_not_returned(self, tmp_cron_dir):
|
|
create_job(prompt="Not yet", schedule="every 1h")
|
|
due = get_due_jobs()
|
|
assert len(due) == 0
|
|
|
|
def test_disabled_not_returned(self, tmp_cron_dir):
|
|
job = create_job(prompt="Disabled", schedule="every 1h")
|
|
jobs = load_jobs()
|
|
jobs[0]["enabled"] = False
|
|
jobs[0]["next_run_at"] = (datetime.now() - timedelta(minutes=5)).isoformat()
|
|
save_jobs(jobs)
|
|
|
|
due = get_due_jobs()
|
|
assert len(due) == 0
|
|
|
|
|
|
class TestSaveJobOutput:
|
|
def test_creates_output_file(self, tmp_cron_dir):
|
|
output_file = save_job_output("test123", "# Results\nEverything ok.")
|
|
assert output_file.exists()
|
|
assert output_file.read_text() == "# Results\nEverything ok."
|
|
assert "test123" in str(output_file)
|