199 lines
6.4 KiB
Python
199 lines
6.4 KiB
Python
"""IANA timezone support for cron schedules.
|
|
|
|
Covers the new ``CronJob.tz`` field + ``validate_tz`` + ``_next_run``'s
|
|
wall-time-in-tz matching, and round-trip persistence of ``tz``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from opensquilla.scheduler.jobs import _next_run
|
|
from opensquilla.scheduler.ops import SchedulerOps
|
|
from opensquilla.scheduler.parser import validate_tz
|
|
from opensquilla.scheduler.payloads import make_agent_turn_payload
|
|
from opensquilla.scheduler.persistence import JobStore
|
|
from opensquilla.scheduler.types import CronJob, ScheduleKind, SessionTarget
|
|
|
|
# --- validate_tz ---------------------------------------------------------
|
|
|
|
|
|
def test_validate_tz_accepts_empty() -> None:
|
|
validate_tz("")
|
|
|
|
|
|
def test_validate_tz_accepts_iana_zone() -> None:
|
|
validate_tz("America/Los_Angeles")
|
|
validate_tz("Asia/Shanghai")
|
|
validate_tz("UTC")
|
|
|
|
|
|
def test_validate_tz_rejects_nonsense() -> None:
|
|
with pytest.raises(ValueError, match="Unknown timezone"):
|
|
validate_tz("Not/A_Real_Zone")
|
|
|
|
|
|
# --- _next_run uses tz ---------------------------------------------------
|
|
|
|
|
|
def _cron_job(expr: str, tz: str = "") -> CronJob:
|
|
return CronJob(
|
|
id="job-1",
|
|
cron_expr=expr,
|
|
handler_key="agent_run",
|
|
payload={"kind": "agent_turn", "task": "x", "agent_id": "main"},
|
|
session_target=SessionTarget.ISOLATED,
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_raw=expr,
|
|
tz=tz,
|
|
)
|
|
|
|
|
|
def test_next_run_la_morning_lands_at_la_local_time() -> None:
|
|
"""0 9 * * * with tz=America/Los_Angeles must fire at 09:00 LA local."""
|
|
from zoneinfo import ZoneInfo
|
|
|
|
# Pick a date when LA is on PST (UTC-8): January 15, 2026.
|
|
after = datetime(2026, 1, 15, 0, 0, tzinfo=UTC) # 16:00 PST prev day
|
|
job = _cron_job("0 9 * * *", tz="America/Los_Angeles")
|
|
next_run = _next_run(job, after)
|
|
la_local = next_run.astimezone(ZoneInfo("America/Los_Angeles"))
|
|
assert la_local.hour == 9
|
|
assert la_local.minute == 0
|
|
# In PST (UTC-8) → 17:00 UTC. In PDT (UTC-7) → 16:00 UTC. Jan 15 is PST.
|
|
assert next_run.hour == 17
|
|
assert next_run.minute == 0
|
|
|
|
|
|
def test_next_run_la_morning_after_dst_lands_at_16_utc() -> None:
|
|
"""In PDT (April), 09:00 LA → 16:00 UTC. Confirms DST is respected."""
|
|
from zoneinfo import ZoneInfo
|
|
|
|
after = datetime(2026, 4, 15, 0, 0, tzinfo=UTC)
|
|
job = _cron_job("0 9 * * *", tz="America/Los_Angeles")
|
|
next_run = _next_run(job, after)
|
|
la_local = next_run.astimezone(ZoneInfo("America/Los_Angeles"))
|
|
assert la_local.hour == 9
|
|
assert next_run.hour == 16
|
|
|
|
|
|
def test_next_run_without_tz_matches_in_utc() -> None:
|
|
"""Legacy behaviour: empty tz → UTC matching."""
|
|
after = datetime(2026, 1, 15, 8, 30, tzinfo=UTC)
|
|
job = _cron_job("0 9 * * *")
|
|
next_run = _next_run(job, after)
|
|
assert next_run.hour == 9
|
|
assert next_run.minute == 0
|
|
assert next_run.tzinfo == UTC
|
|
|
|
|
|
def test_next_run_with_empty_tz_matches_in_utc() -> None:
|
|
after = datetime(2026, 1, 15, 8, 30, tzinfo=UTC)
|
|
job = _cron_job("0 9 * * *", tz="")
|
|
next_run = _next_run(job, after)
|
|
assert next_run.hour == 9
|
|
|
|
|
|
# --- SchedulerOps.add accepts tz ----------------------------------------
|
|
|
|
|
|
async def test_ops_add_persists_tz(tmp_path: Path) -> None:
|
|
db = tmp_path / "cron.db"
|
|
store = JobStore(str(db))
|
|
await store.open()
|
|
try:
|
|
ops = SchedulerOps(store)
|
|
job = await ops.add(
|
|
name="LA brief",
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_value="0 9 * * *",
|
|
handler_key="agent_run",
|
|
payload=make_agent_turn_payload("Morning briefing"),
|
|
session_target=SessionTarget.ISOLATED,
|
|
tz="America/Los_Angeles",
|
|
)
|
|
assert job.tz == "America/Los_Angeles"
|
|
|
|
# Round-trip through the store.
|
|
reloaded = await store.get(job.id)
|
|
assert reloaded is not None
|
|
assert reloaded.tz == "America/Los_Angeles"
|
|
finally:
|
|
await store.close()
|
|
|
|
|
|
async def test_ops_add_rejects_bad_tz(tmp_path: Path) -> None:
|
|
db = tmp_path / "cron.db"
|
|
store = JobStore(str(db))
|
|
await store.open()
|
|
try:
|
|
ops = SchedulerOps(store)
|
|
with pytest.raises(ValueError, match="Unknown timezone"):
|
|
await ops.add(
|
|
name="bad",
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_value="0 9 * * *",
|
|
handler_key="agent_run",
|
|
payload=make_agent_turn_payload("x"),
|
|
session_target=SessionTarget.ISOLATED,
|
|
tz="Mars/Olympus",
|
|
)
|
|
finally:
|
|
await store.close()
|
|
|
|
|
|
async def test_ops_update_changes_tz_and_recomputes_next_run(tmp_path: Path) -> None:
|
|
db = tmp_path / "cron.db"
|
|
store = JobStore(str(db))
|
|
await store.open()
|
|
try:
|
|
ops = SchedulerOps(store)
|
|
job = await ops.add(
|
|
name="briefing",
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_value="0 9 * * *",
|
|
handler_key="agent_run",
|
|
payload=make_agent_turn_payload("brief"),
|
|
session_target=SessionTarget.ISOLATED,
|
|
tz="UTC",
|
|
)
|
|
utc_next = job.next_run_at
|
|
# Now patch tz to LA and reschedule.
|
|
updated = await ops.update(
|
|
job.id,
|
|
tz="America/Los_Angeles",
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_value="0 9 * * *",
|
|
)
|
|
assert updated is not None
|
|
assert updated.tz == "America/Los_Angeles"
|
|
# LA next_run shifts at least 7h relative to UTC schedule.
|
|
assert utc_next is not None
|
|
assert updated.next_run_at is not None
|
|
assert updated.next_run_at != utc_next
|
|
finally:
|
|
await store.close()
|
|
|
|
|
|
async def test_ops_update_rejects_bad_tz(tmp_path: Path) -> None:
|
|
db = tmp_path / "cron.db"
|
|
store = JobStore(str(db))
|
|
await store.open()
|
|
try:
|
|
ops = SchedulerOps(store)
|
|
job = await ops.add(
|
|
name="x",
|
|
schedule_kind=ScheduleKind.CRON,
|
|
schedule_value="0 9 * * *",
|
|
handler_key="agent_run",
|
|
payload=make_agent_turn_payload("x"),
|
|
session_target=SessionTarget.ISOLATED,
|
|
)
|
|
with pytest.raises(ValueError, match="Unknown timezone"):
|
|
await ops.update(job.id, tz="Mars/Olympus")
|
|
finally:
|
|
await store.close()
|