use cuttlefish_host::ledger::Ledger;
use std::sync::Mutex;
static ENV_GUARD: Mutex<()> = Mutex::new(());
#[test]
fn a_fresh_ledger_has_no_checkpoints() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "test-fingerprint").unwrap();
assert_eq!(ledger.get_completed("some_node").unwrap(), None);
}
#[test]
fn a_completed_checkpoint_round_trips() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "test-fingerprint").unwrap();
let output = serde_json::json!({"summary": "hi"});
ledger.write_completed("summarize", &output).unwrap();
assert_eq!(ledger.get_completed("summarize").unwrap(), Some(output));
}
#[test]
fn a_skipped_checkpoint_has_no_output_but_is_recorded() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "test-fingerprint").unwrap();
ledger.write_skipped("handle_pdf").unwrap();
assert!(ledger.is_skipped("handle_pdf").unwrap());
assert_eq!(ledger.get_completed("handle_pdf").unwrap(), None);
}
#[test]
fn job_status_starts_running_and_can_be_finalized() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "test-fingerprint").unwrap();
assert_eq!(
ledger.job_status().unwrap(),
cuttlefish_host::ledger::LedgerJobStatus::Running
);
ledger.finish("completed").unwrap();
assert_eq!(
ledger.job_status().unwrap(),
cuttlefish_host::ledger::LedgerJobStatus::Completed
);
}
#[test]
fn reopening_an_existing_ledger_preserves_its_checkpoints() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.sqlite");
{
let ledger = Ledger::open(&path, "test-fingerprint").unwrap();
ledger.write_completed("a", &serde_json::json!(1)).unwrap();
}
let reopened = Ledger::open(&path, "test-fingerprint").unwrap();
assert_eq!(
reopened.get_completed("a").unwrap(),
Some(serde_json::json!(1))
);
}
#[test]
fn a_real_database_error_is_not_silently_treated_as_no_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.sqlite");
let ledger = Ledger::open(&path, "test-fingerprint").unwrap();
let corrupter = rusqlite::Connection::open(&path).unwrap();
corrupter.execute("DROP TABLE checkpoints", []).unwrap();
drop(corrupter);
let completed = ledger.get_completed("some_node");
assert!(
completed.is_err(),
"expected a real database error, got {completed:?}"
);
assert!(
!matches!(completed, Err(rusqlite::Error::QueryReturnedNoRows)),
"get_completed must not report a genuine error as QueryReturnedNoRows: {completed:?}"
);
let skipped = ledger.is_skipped("some_node");
assert!(
skipped.is_err(),
"expected a real database error, got {skipped:?}"
);
assert!(
!matches!(skipped, Err(rusqlite::Error::QueryReturnedNoRows)),
"is_skipped must not report a genuine error as QueryReturnedNoRows: {skipped:?}"
);
}
#[test]
fn reopening_with_a_different_fingerprint_keeps_the_original() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.sqlite");
Ledger::open(&path, "original").unwrap();
let reopened = Ledger::open(&path, "different").unwrap();
assert_eq!(reopened.graph_fingerprint().unwrap(), "original");
}
#[test]
fn jobs_root_honors_cuttlefish_jobs_home_when_set() {
let _guard = ENV_GUARD.lock().unwrap();
std::env::set_var("CUTTLEFISH_JOBS_HOME", "/tmp/cf-jobs-test");
let root = cuttlefish_host::ledger::jobs_root();
std::env::remove_var("CUTTLEFISH_JOBS_HOME");
assert_eq!(root, Some(std::path::PathBuf::from("/tmp/cf-jobs-test")));
}
#[test]
fn jobs_root_falls_back_to_cuttlefish_home_jobs_when_unset() {
let _guard = ENV_GUARD.lock().unwrap();
std::env::remove_var("CUTTLEFISH_JOBS_HOME");
std::env::set_var("CUTTLEFISH_HOME", "/tmp/cf-home-test");
let root = cuttlefish_host::ledger::jobs_root();
std::env::remove_var("CUTTLEFISH_HOME");
assert_eq!(
root,
Some(std::path::PathBuf::from("/tmp/cf-home-test/jobs"))
);
}
#[test]
fn item_checkpoints_are_independent_of_each_other_and_of_the_node() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "fp").unwrap();
ledger
.write_item_completed("map", 0, &serde_json::json!({"a": 1}), None)
.unwrap();
ledger.write_item_failed("map", 1, "boom", None).unwrap();
assert_eq!(
ledger.get_item_completed("map", 0).unwrap(),
Some(serde_json::json!({"a": 1}))
);
assert_eq!(
ledger.get_item_completed("map", 1).unwrap(),
None,
"a failed item has no output"
);
assert_eq!(
ledger.get_item_completed("map", 2).unwrap(),
None,
"an item that never ran has no output"
);
assert_eq!(ledger.get_completed("map").unwrap(), None);
}
#[test]
fn concluded_items_lists_successes_and_failures_in_index_order() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "fp").unwrap();
ledger
.write_item_completed("map", 2, &serde_json::json!("c"), None)
.unwrap();
ledger
.write_item_completed("map", 0, &serde_json::json!("a"), None)
.unwrap();
ledger
.write_item_failed("map", 1, "bad chunk", None)
.unwrap();
let concluded = ledger.concluded_items("map").unwrap();
assert_eq!(concluded.len(), 3);
assert_eq!(concluded[0], (0, Some(serde_json::json!("a")), None));
assert_eq!(
concluded[1],
(1, None, Some("bad chunk".to_string())),
"written out of order, but must come back in index order"
);
assert_eq!(concluded[2], (2, Some(serde_json::json!("c")), None));
}
#[test]
fn a_pre_item_index_ledger_is_refused_with_an_actionable_message() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.sqlite");
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE checkpoints (
node_name TEXT PRIMARY KEY, status TEXT NOT NULL,
output_json TEXT, completed_at TEXT NOT NULL);
CREATE TABLE job_status (status TEXT NOT NULL, graph_fingerprint TEXT NOT NULL);
INSERT INTO job_status VALUES ('running', 'fp');",
)
.unwrap();
}
let err = Ledger::open(&path, "fp").expect_err("an old-schema ledger must be refused");
let msg = err.to_string();
assert!(msg.contains("re-submit"), "must say what to do: {msg}");
}
#[test]
fn a_changed_manifest_is_detected_on_resume() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "fp").unwrap();
assert!(ledger
.check_or_record_manifest("map", "digest-A", 500)
.unwrap()
.is_ok());
assert!(ledger
.check_or_record_manifest("map", "digest-A", 500)
.unwrap()
.is_ok());
assert!(ledger
.check_or_record_manifest("map", "digest-B", 500)
.unwrap()
.is_err());
}
#[test]
fn a_failed_item_records_its_input_and_a_completed_one_does_not() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "fp").unwrap();
let input = serde_json::json!({"chunk": "the quick brown fox", "doc": "q3.pdf"});
ledger
.write_item_completed("map", 0, &serde_json::json!({"ok": true}), None)
.unwrap();
ledger
.write_item_failed("map", 1, "boom", Some(&input))
.unwrap();
ledger
.write_escalated("map", Some(2), "judge: no numbers cited", Some(&input))
.unwrap();
let drainable = ledger.escalations().unwrap();
assert_eq!(drainable.len(), 1, "only the escalated item is drainable");
assert_eq!(
drainable[0].input.as_ref(),
Some(&input),
"the escalation must carry the input verbatim, or it can't be drained"
);
let all = ledger.concluded_items("map").unwrap();
assert_eq!(all.len(), 3);
}
#[test]
fn draining_marks_rows_without_destroying_them() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("ledger.sqlite"), "fp").unwrap();
let input = serde_json::json!({"n": 2});
ledger
.write_escalated("map", Some(0), "too small", Some(&input))
.unwrap();
assert_eq!(ledger.escalations().unwrap().len(), 1);
ledger.mark_drained("map", Some(0)).unwrap();
assert!(
ledger.escalations().unwrap().is_empty(),
"a drained escalation must drop out of the default listing"
);
let all = ledger.all_escalations().unwrap();
assert_eq!(all.len(), 1, "history must not be destroyed by draining");
assert!(all[0].drained_at.is_some(), "and must be marked drained");
}
#[test]
fn a_ledger_without_the_drain_columns_gains_them_and_still_resumes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.sqlite");
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE checkpoints (
node_name TEXT NOT NULL,
item_index INTEGER NOT NULL DEFAULT -1,
status TEXT NOT NULL,
output_json TEXT,
error_text TEXT,
completed_at TEXT NOT NULL,
PRIMARY KEY (node_name, item_index));
CREATE TABLE job_status (status TEXT NOT NULL, graph_fingerprint TEXT NOT NULL);
INSERT INTO job_status VALUES ('running', 'fp');
INSERT INTO checkpoints VALUES ('map', 3, 'escalated', NULL, 'gave up', 'then');",
)
.unwrap();
}
let ledger = Ledger::open(&path, "fp").expect("an additive migration must not refuse");
let rows = ledger.escalations().unwrap();
assert_eq!(rows.len(), 1, "the old escalation must still be readable");
assert_eq!(rows[0].reason, "gave up");
assert!(
rows[0].input.is_none(),
"it predates input recording, and must say so rather than invent one"
);
ledger
.write_item_completed("map", 4, &serde_json::json!({"ok": true}), None)
.unwrap();
assert!(ledger.item_concluded("map", 4).unwrap());
}
#[test]
fn a_read_only_open_never_writes_to_the_ledger() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ledger.sqlite");
{
let ledger = Ledger::open(&path, "fp").unwrap();
ledger
.write_escalated(
"map",
Some(0),
"gave up",
Some(&serde_json::json!({"n": 1})),
)
.unwrap();
}
let before = std::fs::metadata(&path).unwrap().modified().unwrap();
let reader = Ledger::open_read_only(&path).expect("an existing ledger must open read-only");
assert_eq!(reader.escalations().unwrap().len(), 1, "it must still read");
assert!(
reader.mark_drained("map", Some(0)).is_err(),
"a read-only ledger must refuse writes"
);
assert_eq!(
std::fs::metadata(&path).unwrap().modified().unwrap(),
before,
"opening and reading must not modify the file"
);
}
#[test]
fn a_ledger_without_the_drain_columns_still_reads_read_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("old.sqlite");
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE checkpoints (
node_name TEXT NOT NULL, item_index INTEGER NOT NULL DEFAULT -1,
status TEXT NOT NULL, output_json TEXT, error_text TEXT,
completed_at TEXT NOT NULL, PRIMARY KEY (node_name, item_index));
CREATE TABLE job_status (status TEXT NOT NULL, graph_fingerprint TEXT NOT NULL);
INSERT INTO job_status VALUES ('failed', 'fp');
INSERT INTO checkpoints VALUES ('map', 3, 'escalated', NULL, 'ancient', 'then');",
)
.unwrap();
}
let reader = Ledger::open_read_only(&path).unwrap();
let rows = reader
.escalations()
.expect("an older ledger must still read");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].reason, "ancient");
assert!(rows[0].input.is_none(), "it has no input, and says so");
}