fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""Tests for scheduler_utils (cron / DST / delay / horizon)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
from application.agents.scheduler_utils import (
|
|
ScheduleValidationError,
|
|
clamp_once_horizon,
|
|
cron_interval_seconds,
|
|
next_cron_run,
|
|
parse_cron,
|
|
parse_delay,
|
|
parse_run_at,
|
|
resolve_timezone,
|
|
)
|
|
|
|
|
|
class TestParseCron:
|
|
def test_valid(self):
|
|
parse_cron("0 9 * * 1")
|
|
|
|
def test_invalid(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_cron("not a cron")
|
|
|
|
def test_wrong_field_count(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_cron("0 9 * *")
|
|
|
|
|
|
class TestNextCronRunDST:
|
|
def test_daily_9am_warsaw_across_spring_forward(self):
|
|
tz = ZoneInfo("Europe/Warsaw")
|
|
before_dst = datetime(2026, 3, 28, 9, 30, tzinfo=tz)
|
|
nxt = next_cron_run("0 9 * * *", "Europe/Warsaw", after=before_dst)
|
|
assert nxt.astimezone(tz) == datetime(2026, 3, 29, 9, 0, tzinfo=tz)
|
|
|
|
def test_daily_9am_warsaw_across_fall_back(self):
|
|
tz = ZoneInfo("Europe/Warsaw")
|
|
before_dst = datetime(2026, 10, 24, 9, 30, tzinfo=tz)
|
|
nxt = next_cron_run("0 9 * * *", "Europe/Warsaw", after=before_dst)
|
|
assert nxt.astimezone(tz) == datetime(2026, 10, 25, 9, 0, tzinfo=tz)
|
|
|
|
def test_utc_default(self):
|
|
anchor = datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
nxt = next_cron_run("0 * * * *", None, after=anchor)
|
|
assert nxt > anchor
|
|
assert nxt.tzinfo is not None
|
|
|
|
def test_returned_value_is_utc(self):
|
|
anchor = datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
nxt = next_cron_run("0 9 * * *", "Europe/Warsaw", after=anchor)
|
|
assert nxt.tzinfo is not None
|
|
assert nxt.utcoffset() == timedelta(0)
|
|
|
|
|
|
class TestResolveTimezone:
|
|
def test_unknown(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
resolve_timezone("Atlantis/Nowhere")
|
|
|
|
def test_blank_defaults_utc(self):
|
|
assert resolve_timezone("").key == "UTC"
|
|
assert resolve_timezone(None).key == "UTC"
|
|
|
|
|
|
class TestParseDelay:
|
|
@pytest.mark.parametrize(
|
|
"raw,seconds",
|
|
[("30s", 30), ("15m", 900), ("2h", 7200), ("1d", 86_400)],
|
|
)
|
|
def test_units(self, raw, seconds):
|
|
assert parse_delay(raw).total_seconds() == seconds
|
|
|
|
def test_uppercase(self):
|
|
assert parse_delay("2H").total_seconds() == 7200
|
|
|
|
def test_zero_rejected(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_delay("0m")
|
|
|
|
def test_garbage(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_delay("two hours")
|
|
|
|
|
|
class TestParseRunAt:
|
|
def test_iso_utc(self):
|
|
parsed = parse_run_at("2026-05-19T12:00:00Z")
|
|
assert parsed.tzinfo is not None
|
|
assert parsed == datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
|
|
def test_iso_with_offset(self):
|
|
parsed = parse_run_at("2026-05-19T14:00:00+02:00")
|
|
assert parsed == datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
|
|
def test_naive_uses_tz(self):
|
|
parsed = parse_run_at("2026-05-19T14:00:00", "Europe/Warsaw")
|
|
assert parsed == datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
|
|
def test_invalid(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_run_at("not a date")
|
|
|
|
|
|
class TestCronIntervalSeconds:
|
|
def test_every_minute_returns_60s(self):
|
|
assert cron_interval_seconds("* * * * *", None) == 60
|
|
|
|
def test_hourly_returns_3600s(self):
|
|
assert cron_interval_seconds("0 * * * *", None) == 3600
|
|
|
|
def test_bursty_cron_returns_smallest_gap(self):
|
|
# '* 9 * * *' has 60s gaps inside the 9 AM burst; sampling two adjacent
|
|
# ticks at random can miss them — the rolling window must catch the 60.
|
|
assert cron_interval_seconds("* 9 * * *", None) == 60
|
|
|
|
def test_bursty_cron_rejected_when_floor_above_burst(self):
|
|
from application.core.settings import settings as app_settings
|
|
burst = "* 9 * * *"
|
|
cadence = cron_interval_seconds(burst, None)
|
|
floor = max(0, int(app_settings.SCHEDULE_MIN_INTERVAL))
|
|
assert cadence < floor, (
|
|
f"bursty cron {burst!r} cadence {cadence}s must be below the "
|
|
f"configured SCHEDULE_MIN_INTERVAL floor ({floor}s)"
|
|
)
|
|
|
|
|
|
class TestClampOnceHorizon:
|
|
def test_rejects_past(self):
|
|
past = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
with pytest.raises(ScheduleValidationError):
|
|
clamp_once_horizon(past, max_horizon_seconds=3600)
|
|
|
|
def test_rejects_beyond_horizon(self):
|
|
far = datetime.now(timezone.utc) + timedelta(days=400)
|
|
with pytest.raises(ScheduleValidationError):
|
|
clamp_once_horizon(far, max_horizon_seconds=365 * 86_400)
|
|
|
|
def test_accepts_in_range(self):
|
|
soon = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
clamp_once_horizon(soon, max_horizon_seconds=86_400)
|