mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
test(automations/triggers): lock schedule cron + params
Cover the cron + IANA timezone + UTC normalization contract for the schedule trigger: next-match strictly-after, DST offset shift across spring-forward, malformed cron / unknown timezone rejection, and the ScheduleTriggerParams Pydantic gate that surfaces InvalidCronError as ValidationError at the API boundary. 8 tests, pure unit (no DB, no mocks).
This commit is contained in:
parent
4f202e1fa3
commit
2a76f43387
4 changed files with 116 additions and 0 deletions
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Lock the cron + timezone + UTC normalization contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.automations.triggers.schedule.cron import (
|
||||||
|
InvalidCronError,
|
||||||
|
compute_next_fire_at,
|
||||||
|
validate_cron,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_fire_at_returns_next_match_normalized_to_utc() -> None:
|
||||||
|
"""``compute_next_fire_at`` evaluates the cron in the given IANA timezone
|
||||||
|
and returns the next strictly-later match expressed in UTC.
|
||||||
|
|
||||||
|
Setup: ``0 9 * * 1-5`` (09:00 Monday-Friday) in ``Africa/Kigali``
|
||||||
|
(UTC+2, no DST). With ``after`` = Tuesday 05:00 UTC (= 07:00 local),
|
||||||
|
the next fire is the same Tuesday at 09:00 local = 07:00 UTC.
|
||||||
|
"""
|
||||||
|
after = datetime(2026, 5, 26, 5, 0, tzinfo=UTC) # Tue 07:00 Kigali
|
||||||
|
|
||||||
|
next_fire = compute_next_fire_at("0 9 * * 1-5", "Africa/Kigali", after=after)
|
||||||
|
|
||||||
|
assert next_fire == datetime(2026, 5, 26, 7, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_fire_at_respects_dst_offset_change() -> None:
|
||||||
|
"""A daily cron in a DST-observing tz fires at the same local hour
|
||||||
|
across the DST boundary, which produces a different UTC offset on
|
||||||
|
either side of the transition.
|
||||||
|
|
||||||
|
Setup: ``0 9 * * *`` (09:00 every day) in ``America/New_York``.
|
||||||
|
NY is UTC-5 in winter (EST), UTC-4 in summer (EDT). Evaluating from
|
||||||
|
each side of the spring-forward in 2026 (Sun Mar 8 at 02:00 → 03:00):
|
||||||
|
|
||||||
|
- winter: ``after`` = 2026-02-15 (EST, UTC-5) → next 09:00 EST = 14:00 UTC
|
||||||
|
- summer: ``after`` = 2026-04-15 (EDT, UTC-4) → next 09:00 EDT = 13:00 UTC
|
||||||
|
"""
|
||||||
|
winter_after = datetime(2026, 2, 15, 0, 0, tzinfo=UTC)
|
||||||
|
summer_after = datetime(2026, 4, 15, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
winter_fire = compute_next_fire_at("0 9 * * *", "America/New_York", after=winter_after)
|
||||||
|
summer_fire = compute_next_fire_at("0 9 * * *", "America/New_York", after=summer_after)
|
||||||
|
|
||||||
|
assert winter_fire == datetime(2026, 2, 15, 14, 0, tzinfo=UTC)
|
||||||
|
assert summer_fire == datetime(2026, 4, 15, 13, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_fire_at_is_strictly_after_when_after_equals_a_match() -> None:
|
||||||
|
"""When ``after`` lands exactly on a cron match, the result jumps to the
|
||||||
|
next match — never the same instant. Required so the schedule-tick
|
||||||
|
can pass ``next_fire_at`` itself as ``after`` to advance to the
|
||||||
|
following slot without double-firing.
|
||||||
|
|
||||||
|
Setup: weekday 09:00 Kigali. ``after`` = Mon 09:00 Kigali = 07:00 UTC
|
||||||
|
(an exact match) → next fire must be Tue 09:00 Kigali = next day 07:00 UTC.
|
||||||
|
"""
|
||||||
|
after = datetime(2026, 5, 25, 7, 0, tzinfo=UTC) # Mon 09:00 Kigali — exact match
|
||||||
|
|
||||||
|
next_fire = compute_next_fire_at("0 9 * * 1-5", "Africa/Kigali", after=after)
|
||||||
|
|
||||||
|
assert next_fire == datetime(2026, 5, 26, 7, 0, tzinfo=UTC) # Tue 09:00 Kigali
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_cron_rejects_malformed_cron_expression() -> None:
|
||||||
|
"""A syntactically invalid cron must be rejected at validation time so
|
||||||
|
bad triggers can't reach storage and explode at fire time."""
|
||||||
|
with pytest.raises(InvalidCronError):
|
||||||
|
validate_cron("this is not cron", "UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_cron_rejects_unknown_timezone() -> None:
|
||||||
|
"""A non-IANA timezone string must be rejected at validation time —
|
||||||
|
the same protective gate as the cron expression itself."""
|
||||||
|
with pytest.raises(InvalidCronError):
|
||||||
|
validate_cron("0 9 * * *", "Mars/Olympus_Mons")
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Lock the ``ScheduleTriggerParams`` validation contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.automations.triggers.schedule.params import ScheduleTriggerParams
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_params_accept_valid_cron_and_iana_timezone() -> None:
|
||||||
|
"""A well-formed cron + IANA timezone yields a populated model.
|
||||||
|
Locks the round-trip path users go through when creating a trigger."""
|
||||||
|
params = ScheduleTriggerParams(cron="0 9 * * 1-5", timezone="Africa/Kigali")
|
||||||
|
|
||||||
|
assert params.cron == "0 9 * * 1-5"
|
||||||
|
assert params.timezone == "Africa/Kigali"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_params_reject_malformed_cron_with_validation_error() -> None:
|
||||||
|
"""``InvalidCronError`` from ``validate_cron`` must surface as
|
||||||
|
Pydantic ``ValidationError`` so the FastAPI layer returns 422 instead
|
||||||
|
of letting the bad value reach storage."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ScheduleTriggerParams(cron="not cron", timezone="UTC")
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_params_reject_unknown_timezone_with_validation_error() -> None:
|
||||||
|
"""An unknown timezone is rejected at the API boundary — same gate
|
||||||
|
as the cron expression itself."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ScheduleTriggerParams(cron="0 9 * * *", timezone="Mars/Olympus_Mons")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue