use nodedb::event::scheduler::cron::CronExpr;
use nodedb::event::scheduler::executor::pending_minute_ticks;
use nodedb::event::scheduler::history::JobHistoryStore;
use nodedb::event::scheduler::types::{JobRun, MissedPolicy};
#[test]
fn cron_every_minute() {
let expr = CronExpr::parse("* * * * *").unwrap();
assert!(expr.matches_epoch(0)); assert!(expr.matches_epoch(60)); assert!(expr.matches_epoch(3600)); }
#[test]
fn cron_specific_minute() {
let expr = CronExpr::parse("30 * * * *").unwrap();
assert!(expr.matches_epoch(1800));
assert!(!expr.matches_epoch(0));
}
#[test]
fn cron_specific_hour_and_minute() {
let expr = CronExpr::parse("0 12 * * *").unwrap();
assert!(expr.matches_epoch(43200));
assert!(!expr.matches_epoch(46800));
}
#[test]
fn cron_range() {
let expr = CronExpr::parse("0 9-17 * * *").unwrap();
assert!(expr.matches_epoch(9 * 3600));
assert!(expr.matches_epoch(12 * 3600));
assert!(expr.matches_epoch(17 * 3600));
assert!(!expr.matches_epoch(8 * 3600));
}
#[test]
fn cron_step() {
let expr = CronExpr::parse("*/15 * * * *").unwrap();
assert!(expr.matches_epoch(0));
assert!(expr.matches_epoch(15 * 60));
assert!(expr.matches_epoch(30 * 60));
assert!(!expr.matches_epoch(10 * 60));
}
#[test]
fn cron_invalid_rejected() {
assert!(CronExpr::parse("").is_err());
assert!(CronExpr::parse("* * *").is_err()); assert!(CronExpr::parse("60 * * * *").is_err()); }
#[test]
fn missed_policy_variants() {
assert_eq!(MissedPolicy::Skip.as_str(), "SKIP");
assert_eq!(MissedPolicy::CatchUp.as_str(), "CATCH_UP");
assert_eq!(MissedPolicy::Queue.as_str(), "QUEUE");
assert_eq!(MissedPolicy::from_str_opt("SKIP"), Some(MissedPolicy::Skip));
assert_eq!(
MissedPolicy::from_str_opt("CATCH_UP"),
Some(MissedPolicy::CatchUp)
);
assert!(MissedPolicy::from_str_opt("INVALID").is_none());
}
#[test]
fn job_history_record_and_query() {
let dir = tempfile::tempdir().unwrap();
let store = JobHistoryStore::open(dir.path()).unwrap();
store
.record(JobRun {
schedule_name: "cleanup".into(),
tenant_id: 1,
started_at: 1000,
duration_ms: 50,
success: true,
error: None,
})
.unwrap();
store
.record(JobRun {
schedule_name: "cleanup".into(),
tenant_id: 1,
started_at: 2000,
duration_ms: 30,
success: false,
error: Some("timeout".into()),
})
.unwrap();
let runs = store.last_runs(1, "cleanup", 10);
assert_eq!(runs.len(), 2);
assert!(!runs[0].success); assert!(runs[1].success); }
#[test]
fn scheduler_fires_minute_even_when_observation_jittered() {
let cron = CronExpr::parse("* * * * *").unwrap();
let fired = pending_minute_ticks(Some(0), 61, &cron, 0);
assert_eq!(
fired,
vec![1],
"minute 1 dropped on jittered observation at second 61"
);
}
#[test]
fn scheduler_catches_up_across_skipped_minutes() {
let cron = CronExpr::parse("* * * * *").unwrap();
let fired = pending_minute_ticks(Some(0), 195, &cron, 0);
assert_eq!(
fired,
vec![1, 2, 3],
"skipped minutes not caught up; got {fired:?}"
);
}
#[test]
fn scheduler_daily_cron_survives_jitter_on_trigger_minute() {
let cron = CronExpr::parse("0 3 * * *").unwrap();
let fired = pending_minute_ticks(Some(179), 10821, &cron, 0);
assert_eq!(
fired,
vec![180],
"daily 03:00 schedule silently skipped on jittered tick"
);
}
#[test]
fn scheduler_catchup_filters_through_cron() {
let cron = CronExpr::parse("*/5 * * * *").unwrap();
let fired = pending_minute_ticks(Some(0), 5 * 60 + 3, &cron, 0);
assert_eq!(
fired,
vec![5],
"catch-up must filter through cron; got {fired:?}"
);
}
#[test]
fn scheduler_does_not_refire_same_minute() {
let cron = CronExpr::parse("* * * * *").unwrap();
let fired = pending_minute_ticks(Some(1), 80, &cron, 0);
assert!(
fired.is_empty(),
"minute 1 re-fired within its own minute window; got {fired:?}"
);
}
#[test]
fn job_history_persists_across_reopen() {
let dir = tempfile::tempdir().unwrap();
{
let store = JobHistoryStore::open(dir.path()).unwrap();
store
.record(JobRun {
schedule_name: "s1".into(),
tenant_id: 1,
started_at: 1000,
duration_ms: 10,
success: true,
error: None,
})
.unwrap();
}
let store = JobHistoryStore::open(dir.path()).unwrap();
let runs = store.last_runs(1, "s1", 10);
assert_eq!(runs.len(), 1);
}