use crate::cron::scheduler::{TICK_ALERT_AFTER_FAILURES, TICK_ALERT_MIN_SPACING, alert_due};
use crate::db::CronJobRepository;
use crate::db::Database;
use crate::db::models::CronJob;
use rusqlite::params;
use std::time::Duration;
const SCHED: &str = include_str!("../cron/scheduler.rs");
fn enclosing<'a>(src: &'a str, head: &str) -> Option<&'a str> {
let at = src.find(head)?;
let open = src[at..].find('{')? + at;
let mut depth = 0;
for (i, ch) in src[open..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&src[open..open + i + 1]);
}
}
_ => {}
}
}
None
}
async fn setup() -> (Database, CronJobRepository) {
let db = Database::connect_in_memory()
.await
.expect("Failed to create database");
db.run_migrations().await.expect("Failed to run migrations");
let repo = CronJobRepository::new(db.pool().clone());
(db, repo)
}
fn make_job(name: &str) -> CronJob {
CronJob::new(
name.to_string(),
"0 9 * * *".to_string(),
"UTC".to_string(),
"Test prompt".to_string(),
None,
None,
"off".to_string(),
true,
None,
None,
)
}
async fn corrupt_row(db: &Database, name: &str) {
let name = name.to_string();
db.pool()
.get()
.await
.expect("get connection")
.interact(move |conn| {
conn.execute(
"UPDATE cron_jobs SET id = 'not-a-uuid-' || ?1 WHERE name = ?1",
params![name],
)
})
.await
.expect("interact")
.expect("corrupt the row");
}
#[tokio::test]
async fn one_unreadable_row_does_not_empty_the_enabled_list() {
let (db, repo) = setup().await;
for name in ["job-a", "job-broken", "job-c"] {
repo.insert(&make_job(name)).await.expect("insert");
}
corrupt_row(&db, "job-broken").await;
let jobs = repo.list_enabled().await.expect("read must succeed");
let names: Vec<String> = jobs.iter().map(|j| j.name.clone()).collect();
assert_eq!(names, vec!["job-a".to_string(), "job-c".to_string()]);
}
#[tokio::test]
async fn one_unreadable_row_does_not_empty_the_full_list() {
let (db, repo) = setup().await;
for name in ["job-a", "job-broken", "job-c"] {
repo.insert(&make_job(name)).await.expect("insert");
}
corrupt_row(&db, "job-broken").await;
let jobs = repo.list_all().await.expect("read must succeed");
let names: Vec<String> = jobs.iter().map(|j| j.name.clone()).collect();
assert_eq!(names, vec!["job-a".to_string(), "job-c".to_string()]);
}
#[tokio::test]
async fn a_clean_table_reports_nothing_skipped() {
let (_db, repo) = setup().await;
repo.insert(&make_job("job-a")).await.expect("insert");
repo.insert(&make_job("job-b")).await.expect("insert");
let (jobs, skipped) = repo
.list_enabled_with_skips()
.await
.expect("read must succeed");
assert_eq!(jobs.len(), 2);
assert_eq!(skipped, 0, "a healthy read must not look degraded");
}
#[tokio::test]
async fn an_all_unreadable_table_is_a_degraded_empty_read() {
let (db, repo) = setup().await;
for name in ["job-a", "job-b"] {
repo.insert(&make_job(name)).await.expect("insert");
}
corrupt_row(&db, "job-a").await;
corrupt_row(&db, "job-b").await;
let (jobs, skipped) = repo
.list_enabled_with_skips()
.await
.expect("read must succeed");
assert!(jobs.is_empty());
assert_eq!(skipped, 2, "both rows must be counted, not swallowed");
}
#[test]
fn escalation_waits_for_three_consecutive_failures() {
let hour = Duration::from_secs(3600);
for failures in 0..TICK_ALERT_AFTER_FAILURES {
assert!(
!alert_due(failures, None, hour),
"failure {failures} of {TICK_ALERT_AFTER_FAILURES} must stay log-only"
);
}
assert!(
alert_due(TICK_ALERT_AFTER_FAILURES, None, hour),
"the third consecutive failure must escalate"
);
assert!(
alert_due(TICK_ALERT_AFTER_FAILURES + 7, None, hour),
"a still-broken scheduler stays eligible to escalate"
);
}
#[test]
fn escalation_repeats_at_most_hourly() {
let hour = Duration::from_secs(3600);
let last = hour * 2;
assert!(
!alert_due(9, Some(last), last + Duration::from_secs(120)),
"a tick failure every 60s must not become 1,440 notices a day"
);
assert!(
!alert_due(
9,
Some(last),
last + TICK_ALERT_MIN_SPACING - Duration::from_millis(1)
),
"the window is closed until it is fully elapsed"
);
assert!(
alert_due(9, Some(last), last + TICK_ALERT_MIN_SPACING),
"a still-stopped scheduler re-alerts once the window passes"
);
}
#[test]
fn a_healthy_tick_clears_the_escalation_state() {
let src = SCHED;
let run = enclosing(src, "pub async fn run(self)").expect("run()");
assert!(
run.contains("failures = 0"),
"Ok(()) arm must reset the consecutive-failure counter"
);
assert!(
run.contains("last_alert = None"),
"a recovery must reset the alert clock too, or the next failure is throttled"
);
assert!(
run.contains("alert_due(failures, last_alert, uptime)"),
"run() must gate the notice through the pure pacing helper"
);
assert!(
run.contains("alert_tick_failure("),
"run() must actually send the escalation"
);
}
#[test]
fn a_total_read_failure_is_not_reported_as_nothing_due() {
let src = SCHED;
let tick = enclosing(src, "async fn tick(&self)").expect("tick()");
assert!(
tick.contains("list_enabled_with_skips()"),
"tick() must read the skipped count, not just the surviving rows"
);
assert!(
tick.contains("jobs.is_empty() && skipped > 0"),
"\"nothing readable\" must be turned into an Err so the escalation can see it"
);
assert!(
tick.contains("remember_targets(&jobs)"),
"targets must be cached while the read still works, or a dead scheduler \
has nowhere to shout"
);
assert!(
!tick.contains("self.repo.list_enabled()"),
"the atomic read is the defect; the tick must not go back to it"
);
}
#[test]
fn the_escalation_message_carries_the_cause_chain() {
let src = SCHED;
let alert = enclosing(src, "async fn alert_tick_failure(").expect("alert_tick_failure()");
assert!(
alert.contains("{err:#}"),
"the notice has to say why the schedule stopped"
);
assert!(
!alert.contains("{e}"),
"bare {{e}} is the #1894 defect and would trip its guard test"
);
assert!(
alert.contains("no scheduled job will fire"),
"the notice must say what is stopped, not just that something failed"
);
}