cloudiful-scheduler 0.4.12

Single-job async scheduling library for background work with optional Valkey-backed state.
Documentation
use super::{initial_next_run_at, next_after, stable_seed_hash, validate};
use crate::{CronSchedule, GroupedCronSchedule, InvalidJobKind, SchedulerError};
use chrono::{TimeDelta, TimeZone, Utc};
use chrono_tz::{Asia::Shanghai, UTC};
use std::time::Duration;

#[test]
fn grouped_cron_initial_next_run_uses_current_anchor_when_due_is_still_future() {
    let schedule = GroupedCronSchedule::new(
        CronSchedule::parse("* * * * *").unwrap(),
        Duration::from_secs(40),
        4,
        3,
    );
    let now = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 20).unwrap();

    let next = initial_next_run_at(now, &schedule, UTC).unwrap();

    assert_eq!(next, Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 30).unwrap());
}

#[test]
fn grouped_cron_initial_next_run_skips_to_next_anchor_when_due_is_past() {
    let schedule = GroupedCronSchedule::new(
        CronSchedule::parse("* * * * *").unwrap(),
        Duration::from_secs(40),
        4,
        1,
    );
    let now = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 20).unwrap();

    let next = initial_next_run_at(now, &schedule, UTC).unwrap();

    assert_eq!(next, Utc.with_ymd_and_hms(2026, 4, 3, 1, 3, 10).unwrap());
}

#[test]
fn grouped_cron_members_are_evenly_spaced_inside_spread() {
    let cron = CronSchedule::parse("* * * * *").unwrap();
    let now = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 0).unwrap();

    let next0 = initial_next_run_at(
        now,
        &GroupedCronSchedule::new(cron.clone(), Duration::from_secs(40), 4, 0),
        UTC,
    )
    .unwrap();
    let next1 = initial_next_run_at(
        now,
        &GroupedCronSchedule::new(cron.clone(), Duration::from_secs(40), 4, 1),
        UTC,
    )
    .unwrap();
    let next2 = initial_next_run_at(
        now,
        &GroupedCronSchedule::new(cron.clone(), Duration::from_secs(40), 4, 2),
        UTC,
    )
    .unwrap();
    let next3 = initial_next_run_at(
        now,
        &GroupedCronSchedule::new(cron, Duration::from_secs(40), 4, 3),
        UTC,
    )
    .unwrap();

    assert_eq!(next0, now + TimeDelta::minutes(1));
    assert_eq!(next1, now + TimeDelta::seconds(10));
    assert_eq!(next2, now + TimeDelta::seconds(20));
    assert_eq!(next3, now + TimeDelta::seconds(30));
}

#[test]
fn grouped_cron_group_seed_rotates_slots_discretely() {
    let cron = CronSchedule::parse("* * * * *").unwrap();
    let now = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 0).unwrap();
    let rotation = (stable_seed_hash("site-a") % 4) as i64;

    let seeded = GroupedCronSchedule::new(cron.clone(), Duration::from_secs(40), 4, 0)
        .with_group_seed("site-a");
    let unseeded = GroupedCronSchedule::new(cron, Duration::from_secs(40), 4, rotation as u32);

    let seeded_next = initial_next_run_at(now, &seeded, UTC).unwrap();
    let rotated_next = initial_next_run_at(now, &unseeded, UTC).unwrap();

    assert_eq!(seeded_next, rotated_next);
}

#[test]
fn grouped_cron_next_after_advances_to_same_slot_on_next_anchor() {
    let schedule = GroupedCronSchedule::new(
        CronSchedule::parse("* * * * *").unwrap(),
        Duration::from_secs(40),
        4,
        2,
    );
    let scheduled_at = Utc.with_ymd_and_hms(2026, 4, 3, 1, 2, 20).unwrap();

    let next = next_after(scheduled_at, &schedule, UTC).unwrap().unwrap();

    assert_eq!(next, Utc.with_ymd_and_hms(2026, 4, 3, 1, 3, 20).unwrap());
}

#[test]
fn grouped_cron_uses_scheduler_timezone_for_anchor_calculation() {
    let schedule = GroupedCronSchedule::new(
        CronSchedule::parse("0 9 * * *").unwrap(),
        Duration::from_secs(30 * 60),
        3,
        1,
    );
    let start = Utc.with_ymd_and_hms(2026, 4, 3, 0, 30, 0).unwrap();

    let shanghai_next = initial_next_run_at(start, &schedule, Shanghai).unwrap();
    let utc_next = initial_next_run_at(start, &schedule, UTC).unwrap();

    assert_eq!(
        shanghai_next,
        Utc.with_ymd_and_hms(2026, 4, 3, 1, 10, 0).unwrap()
    );
    assert_eq!(utc_next, Utc.with_ymd_and_hms(2026, 4, 3, 9, 10, 0).unwrap());
}

#[test]
fn grouped_cron_validation_rejects_invalid_parameters() {
    let cron = CronSchedule::parse("* * * * *").unwrap();
    let zero_spread = GroupedCronSchedule::new(cron.clone(), Duration::ZERO, 3, 0);
    let zero_group = GroupedCronSchedule::new(cron.clone(), Duration::from_secs(10), 0, 0);
    let out_of_range = GroupedCronSchedule::new(cron, Duration::from_secs(10), 3, 3);

    let zero_spread_error = validate(&zero_spread, UTC, Utc::now()).unwrap_err();
    let zero_group_error = validate(&zero_group, UTC, Utc::now()).unwrap_err();
    let out_of_range_error = validate(&out_of_range, UTC, Utc::now()).unwrap_err();

    assert!(matches!(
        zero_spread_error,
        SchedulerError::InvalidJob(ref invalid)
            if invalid.kind() == InvalidJobKind::Other
    ));
    assert!(matches!(
        zero_group_error,
        SchedulerError::InvalidJob(ref invalid)
            if invalid.kind() == InvalidJobKind::Other
    ));
    assert!(matches!(
        out_of_range_error,
        SchedulerError::InvalidJob(ref invalid)
            if invalid.kind() == InvalidJobKind::Other
    ));
}

#[test]
fn grouped_cron_validation_rejects_spread_that_reaches_next_anchor() {
    let schedule = GroupedCronSchedule::new(
        CronSchedule::parse("* * * * *").unwrap(),
        Duration::from_secs(60),
        4,
        0,
    );

    let error = validate(&schedule, UTC, Utc::now()).unwrap_err();

    assert!(matches!(
        error,
        SchedulerError::InvalidJob(ref invalid)
            if invalid.kind() == InvalidJobKind::Other
    ));
}