"""Tests for the cron scheduler daemon.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path import subprocess import sys from unittest.mock import AsyncMock, Mock, patch import pytest from openharness.services.cron_scheduler import ( _jobs_due, append_history, execute_job, load_history, start_daemon, run_scheduler_loop, ) @pytest.fixture(autouse=True) def _tmp_dirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Redirect data and log directories to temp.""" data_dir = tmp_path / "data" logs_dir = tmp_path / "logs" data_dir.mkdir() logs_dir.mkdir() monkeypatch.setattr("openharness.services.cron_scheduler.get_data_dir", lambda: data_dir) monkeypatch.setattr("openharness.services.cron_scheduler.get_logs_dir", lambda: logs_dir) # Also redirect the cron registry used by the scheduler monkeypatch.setattr( "openharness.services.cron.get_cron_registry_path", lambda: data_dir / "cron_jobs.json", ) class TestHistory: def test_empty_history(self) -> None: assert load_history() == [] def test_append_and_load(self) -> None: append_history({"name": "j1", "status": "success"}) append_history({"name": "j2", "status": "failed"}) entries = load_history() assert len(entries) == 2 assert entries[0]["name"] == "j1" def test_filter_by_name(self) -> None: append_history({"name": "j1", "status": "success"}) append_history({"name": "j2", "status": "success"}) entries = load_history(job_name="j1") assert len(entries) == 1 assert entries[0]["name"] == "j1" def test_limit(self) -> None: for i in range(10): append_history({"name": f"j{i}", "status": "success"}) entries = load_history(limit=3) assert len(entries) == 3 # Should be the last 3 assert entries[0]["name"] == "j7" class TestJobsDue: def test_due_job(self) -> None: now = datetime.now(timezone.utc) past = (now - timedelta(minutes=5)).isoformat() jobs = [ {"name": "j1", "schedule": "* * * * *", "enabled": True, "next_run": past}, ] due = _jobs_due(jobs, now) assert len(due) == 1 def test_future_job_not_due(self) -> None: now = datetime.now(timezone.utc) future = (now + timedelta(hours=1)).isoformat() jobs = [ {"name": "j1", "schedule": "* * * * *", "enabled": True, "next_run": future}, ] due = _jobs_due(jobs, now) assert len(due) == 0 def test_disabled_job_not_due(self) -> None: now = datetime.now(timezone.utc) past = (now - timedelta(minutes=5)).isoformat() jobs = [ {"name": "j1", "schedule": "* * * * *", "enabled": False, "next_run": past}, ] due = _jobs_due(jobs, now) assert len(due) == 0 def test_invalid_schedule_skipped(self) -> None: now = datetime.now(timezone.utc) past = (now - timedelta(minutes=5)).isoformat() jobs = [ {"name": "j1", "schedule": "not valid", "enabled": True, "next_run": past}, ] due = _jobs_due(jobs, now) assert len(due) == 0 def test_missing_next_run_skipped(self) -> None: now = datetime.now(timezone.utc) jobs = [ {"name": "j1", "schedule": "* * * * *", "enabled": True}, ] due = _jobs_due(jobs, now) assert len(due) == 0 class TestExecuteJob: @pytest.mark.asyncio async def test_successful_job(self) -> None: job = {"name": "echo-test", "command": "echo hello", "cwd": "/tmp"} entry = await execute_job(job) assert entry["status"] == "success" assert entry["returncode"] == 0 assert "hello" in entry["stdout"] @pytest.mark.asyncio async def test_failing_job(self) -> None: job = {"name": "fail-test", "command": "exit 1", "cwd": "/tmp"} entry = await execute_job(job) assert entry["status"] == "failed" assert entry["returncode"] == 1 @pytest.mark.asyncio async def test_timeout_job(self) -> None: with patch("openharness.services.cron_scheduler.asyncio.wait_for") as mock_wait: import asyncio mock_wait.side_effect = asyncio.TimeoutError() # Need to mock create_subprocess_exec to return a mock process mock_process = AsyncMock() mock_process.communicate = Mock(return_value=object()) mock_process.kill = Mock() mock_process.wait = AsyncMock() with patch( "openharness.utils.shell.asyncio.create_subprocess_exec", return_value=mock_process, ): job = {"name": "slow-test", "command": "sleep 999", "cwd": "/tmp"} entry = await execute_job(job) assert entry["status"] == "timeout" class TestSchedulerLoop: @pytest.mark.asyncio async def test_once_mode_with_no_jobs(self) -> None: """Scheduler loop in once-mode should complete without error when no jobs exist.""" await run_scheduler_loop(once=True) @pytest.mark.asyncio async def test_once_mode_fires_due_job(self) -> None: """Scheduler loop should fire a job that is due.""" from openharness.services.cron import upsert_cron_job upsert_cron_job({"name": "test-once", "schedule": "* * * * *", "command": "echo fired"}) # Force next_run to the past so it's immediately due from openharness.services.cron import load_cron_jobs, save_cron_jobs jobs = load_cron_jobs() now = datetime.now(timezone.utc) jobs[0]["next_run"] = (now - timedelta(minutes=1)).isoformat() save_cron_jobs(jobs) await run_scheduler_loop(once=True) entries = load_history(job_name="test-once") assert len(entries) == 1 assert entries[0]["status"] == "success" @pytest.mark.asyncio async def test_once_mode_handles_signal_registration_failures(self) -> None: """Signal handler setup should not block the scheduler loop on unsupported platforms.""" with patch("openharness.services.cron_scheduler.signal.signal", side_effect=ValueError()): await run_scheduler_loop(once=True) class TestDaemonStartup: def test_start_daemon_uses_portable_process_spawn(self, monkeypatch: pytest.MonkeyPatch) -> None: """Windows startup should use subprocess spawning instead of fork.""" monkeypatch.setattr("openharness.services.cron_scheduler.get_platform", lambda: "windows") monkeypatch.setattr(subprocess, "DETACHED_PROCESS", 0x00000008, raising=False) monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False) fake_process = Mock() fake_process.pid = 4321 fake_process.poll.side_effect = [None] spawned: dict[str, object] = {} def _fake_popen(argv, **kwargs): spawned["argv"] = argv spawned["kwargs"] = kwargs return fake_process with patch("openharness.services.cron_scheduler.subprocess.Popen", side_effect=_fake_popen), patch( "openharness.services.cron_scheduler.read_pid", side_effect=[None, 4321], ): pid = start_daemon() assert pid == 4321 assert spawned["argv"] == [sys.executable, "-m", "openharness.services.cron_scheduler"] assert spawned["kwargs"]["creationflags"] == 0x00000208 assert spawned["kwargs"]["stdin"] is subprocess.DEVNULL assert spawned["kwargs"]["stdout"] is subprocess.DEVNULL assert spawned["kwargs"]["stderr"] is subprocess.DEVNULL assert spawned["kwargs"]["close_fds"] is True