diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b61d9aa..668fcb5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -787,15 +787,19 @@ def cron_add( store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - job = service.add_job( - name=name, - schedule=schedule, - message=message, - deliver=deliver, - to=to, - channel=channel, - ) - + try: + job = service.add_job( + name=name, + schedule=schedule, + message=message, + deliver=deliver, + to=to, + channel=channel, + ) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) from e + console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 14666e8..7ae1153 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -45,6 +45,20 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: return None +def _validate_schedule_for_add(schedule: CronSchedule) -> None: + """Validate schedule fields that would otherwise create non-runnable jobs.""" + if schedule.tz and schedule.kind != "cron": + raise ValueError("tz can only be used with cron schedules") + + if schedule.kind == "cron" and schedule.tz: + try: + from zoneinfo import ZoneInfo + + ZoneInfo(schedule.tz) + except Exception: + raise ValueError(f"unknown timezone '{schedule.tz}'") from None + + class CronService: """Service for managing and executing scheduled jobs.""" @@ -272,6 +286,7 @@ class CronService: ) -> CronJob: """Add a new job.""" store = self._load_store() + _validate_schedule_for_add(schedule) now = _now_ms() job = CronJob( diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py new file mode 100644 index 0000000..bce1ef5 --- /dev/null +++ b/tests/test_cron_commands.py @@ -0,0 +1,29 @@ +from typer.testing import CliRunner + +from nanobot.cli.commands import app + +runner = CliRunner() + + +def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) + + result = runner.invoke( + app, + [ + "cron", + "add", + "--name", + "demo", + "--message", + "hello", + "--cron", + "0 9 * * *", + "--tz", + "America/Vancovuer", + ], + ) + + assert result.exit_code == 1 + assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout + assert not (tmp_path / "cron" / "jobs.json").exists() diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py new file mode 100644 index 0000000..07e990a --- /dev/null +++ b/tests/test_cron_service.py @@ -0,0 +1,30 @@ +import pytest + +from nanobot.cron.service import CronService +from nanobot.cron.types import CronSchedule + + +def test_add_job_rejects_unknown_timezone(tmp_path) -> None: + service = CronService(tmp_path / "cron" / "jobs.json") + + with pytest.raises(ValueError, match="unknown timezone 'America/Vancovuer'"): + service.add_job( + name="tz typo", + schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancovuer"), + message="hello", + ) + + assert service.list_jobs(include_disabled=True) == [] + + +def test_add_job_accepts_valid_timezone(tmp_path) -> None: + service = CronService(tmp_path / "cron" / "jobs.json") + + job = service.add_job( + name="tz ok", + schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"), + message="hello", + ) + + assert job.schedule.tz == "America/Vancouver" + assert job.state.next_run_at_ms is not None