crates/nornir-jobs/src/selftest.rs
//! # Functional-status emit bridge — `nornir-jobs` → test matrix
//!
//! The single internal call site this crate uses to push a *functional* row
//! into the [`nornir_testmatrix`] matrix, so `nornir test --features testmatrix`
//! records whether `nornir-jobs`'s real job lifecycle (start→running,
//! finish→done, fail→failed, submit, list/poll, prune) ACTUALLY works.
//!
//! ## Release strips everything
//! The `testmatrix` cargo feature gates the whole bridge: with it OFF (the
//! release default) [`emit`] is an `#[inline]` no-op and `nornir-testmatrix` is
//! not even linked. Lit ONLY under `cargo … --features testmatrix`.
/// The aspect tag every self-emitted row carries (matches `functional_row`).
pub const ASPECT_FUNCTIONAL: &str = "functional";
/// Push ONE functional-status row for a single real check (feature ON).
#[cfg(feature = "testmatrix")]
#[inline]
pub fn emit(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
/// No-op stub when the `testmatrix` feature is OFF (release builds).
#[cfg(not(feature = "testmatrix"))]
#[inline]
pub fn emit(_component: &str, _check: &str, _ok: bool, _detail: &str) {}
/// `assert!`-with-emit: assert on the real return value AND record the verdict
/// as a matrix row.
#[macro_export]
macro_rules! assert_emit {
($component:expr, $check:expr, $ok:expr, $($detail:tt)+) => {{
let __ok: bool = $ok;
let __detail = ::std::format!($($detail)+);
$crate::selftest::emit($component, $check, __ok, &__detail);
::std::assert!(__ok, "{}::{} — {}", $component, $check, __detail);
}};
}
// ─── inject-and-assert wiring test (feature ON only) ─────────────────────────
#[cfg(all(test, feature = "testmatrix"))]
mod wiring {
use crate::{kind, status, JobRecord, JobSelector, JobStore};
/// Drive `nornir-jobs`'s real lifecycle on a synthetic redb ledger, emit an
/// honest row per surface, then drain the process-global functional buffer
/// and assert the rows landed — proving the emit bridge is wired end to end.
#[test]
fn emits_honest_rows_for_job_lifecycle() {
let dir = tempfile::tempdir().unwrap();
let store = JobStore::open(dir.path()).unwrap();
// Surface: start → a `running` row is visible in the ledger (poll).
let handle = store.start(
kind::RELEASE_RUN,
"nornir",
"ws",
serde_json::json!({ "n": 1 }),
);
let jid = handle.job_id().to_string();
let running = store.list(&JobSelector::All).unwrap();
let running_ok = running
.iter()
.any(|r| r.job_id == jid && r.status == status::RUNNING);
assert_emit!(
"nornir-jobs",
"start_writes_running_row",
running_ok,
"job_id={jid}"
);
// Surface: finish → the same row flips terminal `done`.
handle.finish(serde_json::json!({ "done": true }), "release_events:abc");
let done = store.list(&JobSelector::All).unwrap();
let done_ok = done
.iter()
.any(|r| r.job_id == jid && r.status == status::DONE && r.is_terminal());
assert_emit!(
"nornir-jobs",
"finish_marks_terminal_done",
done_ok,
"job_id={jid}"
);
// Surface: fail → a terminal `failed` row carrying the error.
let h2 = store.start(kind::RELEASE_RUN, "nornir", "ws", serde_json::json!({}));
let jid2 = h2.job_id().to_string();
h2.fail(&anyhow::anyhow!("synthetic failure"));
let failed = store.list(&JobSelector::All).unwrap();
let failed_ok = failed
.iter()
.any(|r| r.job_id == jid2 && r.status == status::FAILED && r.is_terminal());
assert_emit!(
"nornir-jobs",
"fail_marks_terminal_failed",
failed_ok,
"job_id={jid2}"
);
// Surface: submit (thin-client write seam) upserts a supplied record.
let old_micros = chrono::Utc::now().timestamp_micros() - 10 * 86_400 * 1_000_000;
let rec = JobRecord {
job_id: "manual-old".to_string(),
kind: kind::RELEASE_RUN.to_string(),
target: "nornir".to_string(),
workspace: "ws".to_string(),
status: status::DONE.to_string(),
ts_start_micros: old_micros,
ts_end_micros: Some(old_micros + 1000),
elapsed_ms: Some(1),
detail_json: String::new(),
result_ref: String::new(),
parent_id: None,
node: None,
// Not a daemon job, so there is no last-known `DaemonPhase` to mirror
// onto the row. Named explicitly rather than filled by a struct-update
// default: this initializer is the reason the whole module went dark
// when `jera::jobs::JobRecord` grew this field, and a `..Default`
// would have hidden the next such widening the same way.
phase: None,
actor: None,
actor_method: None,
};
store.submit(&rec).unwrap();
let submit_ok = store
.list(&JobSelector::Workspace("ws".to_string()))
.unwrap()
.iter()
.any(|r| r.job_id == "manual-old");
assert_emit!(
"nornir-jobs",
"submit_upserts_record",
submit_ok,
"manual-old present"
);
// Surface: prune drops terminal jobs older than the retention window —
// the ancient `manual-old` goes, the just-finished recent rows stay.
let removed = store.prune(1, crate::CAP).unwrap();
let gone = !store
.list(&JobSelector::All)
.unwrap()
.iter()
.any(|r| r.job_id == "manual-old");
let prune_ok = removed >= 1 && gone;
assert_emit!(
"nornir-jobs",
"prune_drops_aged_terminal",
prune_ok,
"removed={removed}"
);
let rows = nornir_testmatrix::drain_functional_rows();
for want in [
"start_writes_running_row",
"finish_marks_terminal_done",
"fail_marks_terminal_failed",
"submit_upserts_record",
"prune_drops_aged_terminal",
] {
assert!(
rows.iter()
.any(|r| r.suite == "nornir-jobs" && r.test_name == want && r.status == "pass"),
"missing green row {want}: {rows:?}"
);
}
assert!(
rows.iter().all(|r| r.aspect == "functional"),
"rows={rows:?}"
);
}
}