"""Tests for the /api/cron endpoint (echo-core flat schema, no UTC→local conversion).""" from __future__ import annotations import json import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] DASH = PROJECT_ROOT / "dashboard" if str(DASH) not in sys.path: sys.path.insert(0, str(DASH)) @pytest.fixture(scope="module") def cron_module(): from handlers import cron as _c # type: ignore return _c @pytest.fixture def handler(cron_module): class _Stub(cron_module.CronHandlers): def __init__(self): self.captured = None self.captured_code = None def send_json(self, data, code=200): self.captured = data self.captured_code = code return _Stub() def _write_jobs(tmp_path: Path, jobs: list) -> Path: cron_dir = tmp_path / "cron" cron_dir.mkdir(parents=True, exist_ok=True) (cron_dir / "jobs.json").write_text(json.dumps(jobs), encoding="utf-8") return tmp_path def test_parse_cron_time_no_utc_conversion(cron_module): """Echo-core cron strings are already Europe/Bucharest; no +3 shift.""" # 10:00 Bucharest should stay 10:00 in the display. assert cron_module._parse_cron_time("0 10 * * *") == "10:00" assert cron_module._parse_cron_time("30 8 * * 1-5") == "08:30" def test_parse_cron_time_handles_hour_range(cron_module): """A cron like `0 9-17 * * *` should display the starting hour.""" assert cron_module._parse_cron_time("0 9-17 * * *") == "09:00" def test_parse_cron_time_falls_back_on_unexpected_expr(cron_module): assert cron_module._parse_cron_time("@hourly") == "@hourly"[:15] assert cron_module._parse_cron_time("bad") == "bad" def test_iso_to_epoch_ms_handles_empty(cron_module): assert cron_module._iso_to_epoch_ms("") == 0 assert cron_module._iso_to_epoch_ms(None) == 0 assert cron_module._iso_to_epoch_ms("not a date") == 0 def test_iso_to_epoch_ms_parses_iso(cron_module): # Well-known epoch ms = cron_module._iso_to_epoch_ms("1970-01-01T00:00:00+00:00") assert ms == 0 def test_missing_jobs_file_returns_empty(tmp_path, monkeypatch, handler): import constants # type: ignore monkeypatch.setattr(constants, "BASE_DIR", tmp_path) handler.handle_cron_status() assert handler.captured["jobs"] == [] assert "No jobs file" in handler.captured["error"] def test_non_list_jobs_file_is_rejected(tmp_path, monkeypatch, handler): import constants # type: ignore cron_dir = tmp_path / "cron" cron_dir.mkdir() (cron_dir / "jobs.json").write_text('{"not": "a list"}', encoding="utf-8") monkeypatch.setattr(constants, "BASE_DIR", tmp_path) handler.handle_cron_status() assert handler.captured["jobs"] == [] assert "shape" in handler.captured["error"].lower() def test_disabled_jobs_are_filtered_out(tmp_path, monkeypatch, handler): import constants # type: ignore _write_jobs(tmp_path, [ {"name": "foo", "cron": "0 10 * * *", "enabled": True}, {"name": "bar", "cron": "0 11 * * *", "enabled": False}, ]) monkeypatch.setattr(constants, "BASE_DIR", tmp_path) handler.handle_cron_status() names = [j["name"] for j in handler.captured["jobs"]] assert names == ["foo"] assert handler.captured["total"] == 1 def test_frontend_shape_is_preserved(tmp_path, monkeypatch, handler): """Output must carry: id, name, time, schedule, ranToday, lastStatus, lastRunAtMs, nextRunAtMs.""" import constants # type: ignore _write_jobs(tmp_path, [ { "name": "anaf-monitor", "cron": "0 10 * * 1-5", "enabled": True, "last_run": "2026-04-21T10:00:00+03:00", "next_run": "2026-04-22T10:00:00+03:00", "last_status": "ok", }, ]) monkeypatch.setattr(constants, "BASE_DIR", tmp_path) handler.handle_cron_status() jobs = handler.captured["jobs"] assert len(jobs) == 1 j = jobs[0] for k in ("id", "name", "time", "schedule", "ranToday", "lastStatus", "lastRunAtMs", "nextRunAtMs"): assert k in j, f"missing key {k!r} in response" # Echo-core has no separate id — fallback is name. assert j["id"] == "anaf-monitor" assert j["name"] == "anaf-monitor" assert j["time"] == "10:00" assert j["schedule"] == "0 10 * * 1-5" assert j["lastRunAtMs"] > 0 assert j["nextRunAtMs"] is not None and j["nextRunAtMs"] > 0 def test_ran_today_is_based_on_last_run_ms(tmp_path, monkeypatch, handler): """If last_run is in the past (before today 00:00), ranToday is False and lastStatus is None.""" import constants # type: ignore _write_jobs(tmp_path, [ { "name": "ancient", "cron": "0 5 * * *", "enabled": True, "last_run": "2020-01-01T05:00:00+03:00", "next_run": "2026-04-22T05:00:00+03:00", "last_status": "ok", }, ]) monkeypatch.setattr(constants, "BASE_DIR", tmp_path) handler.handle_cron_status() j = handler.captured["jobs"][0] assert j["ranToday"] is False # lastStatus is only surfaced when ranToday — else it's None. assert j["lastStatus"] is None def test_jobs_are_sorted_by_display_time(tmp_path, monkeypatch, handler): import constants # type: ignore _write_jobs(tmp_path, [ {"name": "late", "cron": "0 22 * * *", "enabled": True}, {"name": "early", "cron": "0 5 * * *", "enabled": True}, {"name": "mid", "cron": "0 12 * * *", "enabled": True}, ]) monkeypatch.setattr(constants, "BASE_DIR", tmp_path) handler.handle_cron_status() names = [j["name"] for j in handler.captured["jobs"]] assert names == ["early", "mid", "late"]