Single-commit clean baseline after security scrub of niche-tells, project codenames, internal jargon, and contributor-email leaks. Contents: - 100 Rust crates (_primitives/_rust/) - 37 agent manifests (_manifests/) + generated specs (_generated/) - 67 user-invocable skills (skills/) - 33 hooks (hooks/) - Composition blocks (_blocks/) - Documentation (docs/, README.md) - TS adapter packages (_ts_packages/) - Assembler (_assembler/) - Roles (_roles/) - Templates (_templates/) - Forgejo CI (.forgejo/) Author: Denis Parfionovich <info@greendragon.info> License: see LICENSE.
189 lines
6.6 KiB
Rust
189 lines
6.6 KiB
Rust
//! Integration tests for kei-scheduler. Uses `Store::open_memory` so
|
|
//! each test owns a throwaway DB and a deterministic wall clock
|
|
//! (`now` passed explicitly where the API allows).
|
|
//!
|
|
//! `schedule()` + `cancel()` internally read `Utc::now()` once; that's
|
|
//! fine because we check relative ordering (`next_run_at` compared to
|
|
//! a post-call `Utc::now()` lower bound), not absolute values.
|
|
|
|
use chrono::Utc;
|
|
use kei_scheduler::{
|
|
cancel, compute_next, get_task, list_due, mark_run, open_memory, schedule,
|
|
task_status, ParseError, Store, AT, CRON, INTERVAL,
|
|
};
|
|
|
|
fn store() -> Store {
|
|
open_memory().expect("in-memory store opens clean")
|
|
}
|
|
|
|
#[test]
|
|
fn cron_schedule_sets_future_next_run() {
|
|
let s = store();
|
|
let before = Utc::now().timestamp();
|
|
let id = schedule(s.conn(), "cron1", CRON, "*/5 * * * *", "echo").unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
assert_eq!(t.name, "cron1");
|
|
assert_eq!(t.trigger_kind, "cron");
|
|
assert_eq!(t.status, task_status::PENDING);
|
|
let next = t.next_run_at.expect("cron trigger must produce a next_run_at");
|
|
assert!(next >= before, "next_run_at {next} must be >= launch time {before}");
|
|
}
|
|
|
|
#[test]
|
|
fn at_schedule_with_future_ts_matches_iso_parse() {
|
|
let s = store();
|
|
// 2030-01-01T00:00:00Z → unix 1893456000 (verified via
|
|
// chrono::DateTime::parse_from_rfc3339 at test time).
|
|
let id = schedule(s.conn(), "at1", AT, "2030-01-01T00:00:00Z", "echo").unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
assert_eq!(t.next_run_at, Some(1_893_456_000));
|
|
assert_eq!(t.trigger_kind, "at");
|
|
}
|
|
|
|
#[test]
|
|
fn interval_schedule_sets_now_plus_secs() {
|
|
let s = store();
|
|
let before = Utc::now().timestamp();
|
|
let id = schedule(s.conn(), "int1", INTERVAL, "3600", "echo").unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
let next = t.next_run_at.expect("interval trigger must set next_run_at");
|
|
let after = Utc::now().timestamp();
|
|
assert!(next >= before + 3600);
|
|
assert!(next <= after + 3600);
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_sets_status_and_clears_next_run() {
|
|
let s = store();
|
|
let id = schedule(s.conn(), "tcan", INTERVAL, "60", "echo").unwrap();
|
|
cancel(s.conn(), id).unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
assert_eq!(t.status, task_status::CANCELLED);
|
|
assert_eq!(t.next_run_at, None);
|
|
}
|
|
|
|
#[test]
|
|
fn list_due_returns_eligible_pending_tasks() {
|
|
let s = store();
|
|
// An interval with spec=60 produces next_run ≈ now+60. Query with
|
|
// now+120 to make sure it's due.
|
|
schedule(s.conn(), "due1", INTERVAL, "60", "echo").unwrap();
|
|
let query_ts = Utc::now().timestamp() + 120;
|
|
let due = list_due(s.conn(), query_ts).unwrap();
|
|
assert_eq!(due.len(), 1);
|
|
assert_eq!(due[0].name, "due1");
|
|
assert_eq!(due[0].status, task_status::PENDING);
|
|
}
|
|
|
|
#[test]
|
|
fn list_due_excludes_cancelled_tasks() {
|
|
let s = store();
|
|
let id = schedule(s.conn(), "cx", INTERVAL, "60", "echo").unwrap();
|
|
cancel(s.conn(), id).unwrap();
|
|
let due = list_due(s.conn(), Utc::now().timestamp() + 100_000).unwrap();
|
|
assert!(due.is_empty(), "cancelled tasks must not appear in list_due");
|
|
}
|
|
|
|
#[test]
|
|
fn mark_run_on_interval_advances_next_run() {
|
|
let s = store();
|
|
let id = schedule(s.conn(), "mrint", INTERVAL, "3600", "echo").unwrap();
|
|
let now = 2_000_000_000;
|
|
mark_run(s.conn(), id, 0, now).unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
assert_eq!(t.last_run_at, Some(now));
|
|
assert_eq!(t.last_exit_code, Some(0));
|
|
assert_eq!(t.next_run_at, Some(now + 3600));
|
|
assert_eq!(t.status, task_status::SCHEDULED);
|
|
}
|
|
|
|
#[test]
|
|
fn mark_run_on_at_completes_task() {
|
|
let s = store();
|
|
let id = schedule(s.conn(), "mrat", AT, "2030-01-01T00:00:00Z", "echo").unwrap();
|
|
mark_run(s.conn(), id, 0, 1_893_456_005).unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
assert_eq!(t.status, task_status::DONE);
|
|
assert_eq!(t.next_run_at, None);
|
|
assert_eq!(t.last_exit_code, Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn mark_run_on_cron_recomputes_next() {
|
|
let s = store();
|
|
let id = schedule(s.conn(), "mrcron", CRON, "*/5 * * * *", "echo").unwrap();
|
|
let now: i64 = 2_000_000_000;
|
|
mark_run(s.conn(), id, 0, now).unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
let next = t.next_run_at.expect("cron mark_run must recompute next_run_at");
|
|
// `*/5 * * * *` = every 5 minutes at second-0; next must be within
|
|
// 300 seconds of `now` and strictly greater.
|
|
assert!(next > now);
|
|
assert!(next <= now + 300, "next {next} must be within 5m of now {now}");
|
|
assert_eq!(t.status, task_status::SCHEDULED);
|
|
}
|
|
|
|
#[test]
|
|
fn mark_run_with_nonzero_exit_at_sets_failed() {
|
|
let s = store();
|
|
let id = schedule(s.conn(), "failat", AT, "2030-06-15T12:00:00Z", "echo").unwrap();
|
|
mark_run(s.conn(), id, 17, 1_910_000_000).unwrap();
|
|
let t = get_task(s.conn(), id).unwrap().unwrap();
|
|
assert_eq!(t.status, task_status::FAILED);
|
|
assert_eq!(t.last_exit_code, Some(17));
|
|
assert_eq!(t.next_run_at, None);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_cron_returns_parse_error() {
|
|
let err = compute_next(CRON, "not a cron expression", 0).unwrap_err();
|
|
assert!(
|
|
matches!(err, ParseError::InvalidCron(_, _)),
|
|
"expected InvalidCron, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_iso_datetime_returns_parse_error() {
|
|
let err = compute_next(AT, "not-a-date", 0).unwrap_err();
|
|
assert!(
|
|
matches!(err, ParseError::InvalidIsoDatetime(_)),
|
|
"expected InvalidIsoDatetime, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_trigger_kind_is_rejected() {
|
|
let s = store();
|
|
let err = schedule(s.conn(), "bad", "weekly", "whatever", "echo")
|
|
.expect_err("unknown kind must fail");
|
|
assert!(
|
|
matches!(err, kei_scheduler::Error::Parse(ParseError::UnknownKind(_))),
|
|
"expected UnknownKind, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_name_is_rejected_typed() {
|
|
let s = store();
|
|
schedule(s.conn(), "dup", INTERVAL, "60", "echo").unwrap();
|
|
let err = schedule(s.conn(), "dup", INTERVAL, "120", "echo")
|
|
.expect_err("unique-name collision must fail");
|
|
assert!(
|
|
matches!(err, kei_scheduler::Error::NameExists(ref n) if n == "dup"),
|
|
"expected NameExists(dup), got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zero_interval_is_rejected() {
|
|
let err = compute_next(INTERVAL, "0", 100).unwrap_err();
|
|
assert!(matches!(err, ParseError::InvalidInterval(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn at_in_the_past_returns_none() {
|
|
// 2020-01-01 with `from = 2030-era` → no future fire.
|
|
let next = compute_next(AT, "2020-01-01T00:00:00Z", 1_893_456_000).unwrap();
|
|
assert_eq!(next, None);
|
|
}
|