use super::*;
use crate::test_support::{fixture, remove};
fn journal_path(name: &str) -> (PathBuf, PathBuf) {
let parent: PathBuf = fixture(name);
let path: PathBuf = parent.join(".journal.json");
return (parent, path);
}
#[test]
fn a_missing_file_is_an_empty_journal() {
let (parent, path) = journal_path("journal-empty");
let journal: Journal = Journal::load(path.as_path()).expect("load");
assert!(!journal.is_persisted() && journal.state("copy").is_none());
assert!(!journal.is_done("copy") && !journal.has_begun("copy"));
assert_eq!(journal.path(), path.as_path());
remove(parent.as_path());
}
#[test]
fn the_states_answer_the_predicates() {
let (parent, path) = journal_path("journal-states");
let mut journal: Journal = Journal::load(path.as_path()).expect("load");
journal.mark("copy", "started").expect("mark");
assert!(journal.has_begun("copy") && !journal.is_done("copy"));
journal.mark("copy", "done").expect("mark");
assert!(journal.has_begun("copy") && journal.is_done("copy"));
journal.mark("exchange", "rolled-back").expect("mark");
assert!(!journal.has_begun("exchange") && !journal.is_done("exchange"));
assert_eq!(journal.state("exchange"), Some("rolled-back"));
assert!(journal.is_persisted());
remove(parent.as_path());
}
#[test]
fn a_marked_step_survives_a_reload_and_leaves_no_temporary_file() {
let (parent, path) = journal_path("journal-reload");
let mut journal: Journal = Journal::load(path.as_path()).expect("load");
journal.mark("create", "done").expect("mark");
journal.mark("copy", "started").expect("mark");
let reloaded: Journal = Journal::load(path.as_path()).expect("reload");
assert!(reloaded.is_persisted() && reloaded.is_done("create") && reloaded.has_begun("copy") && !reloaded.is_done("copy"));
assert_eq!(reloaded.started, journal.started);
let names: Vec<String> = std::fs::read_dir(parent.as_path()).expect("listing").flatten().map(|item| { return item.file_name().to_string_lossy().into_owned(); }).collect();
assert_eq!(names, vec![String::from(".journal.json")]);
remove(parent.as_path());
}
#[test]
fn a_file_that_is_not_a_journal_is_refused_by_name() {
let (parent, path) = journal_path("journal-broken");
let cases: [(&str, &str); 6] = [
("not json", "is not valid JSON"),
("{\"schema\": 2, \"started\": \"x\", \"steps\": {}}", "unknown schema version"),
("{\"started\": \"x\", \"steps\": {}}", "unknown schema version"),
("{\"schema\": 1, \"steps\": {}}", "has no start time"),
("{\"schema\": 1, \"started\": \"x\"}", "has no steps"),
("{\"schema\": 1, \"started\": \"x\", \"steps\": {\"copy\": {}}}", "step without a state"),
];
for (text, why) in cases {
std::fs::write(path.as_path(), text).expect("write");
let error: MigrateError = Journal::load(path.as_path()).expect_err("refused");
let shown: String = error.to_string();
assert!(shown.contains(why) && shown.contains(path.to_str().expect("path")), "{text}: {shown}");
}
remove(parent.as_path());
}
#[test]
fn an_unreadable_journal_is_an_error_not_an_empty_one() {
let (parent, path) = journal_path("journal-directory");
std::fs::create_dir(path.as_path()).expect("a directory where the journal would be");
let error: MigrateError = Journal::load(path.as_path()).expect_err("refused");
assert!(error.to_string().contains("cannot read the journal"), "{error}");
remove(parent.as_path());
}
#[test]
fn a_journal_that_cannot_be_written_is_reported() {
let parent: PathBuf = fixture("journal-unwritable");
let path: PathBuf = parent.join("absent-directory").join(".journal.json");
let mut journal: Journal = Journal::load(path.as_path()).expect("load");
let error: MigrateError = journal.mark("copy", "started").expect_err("no directory to write in");
assert!(error.to_string().contains("cannot write the journal"), "{error}");
assert!(!journal.is_persisted());
remove(parent.as_path());
}
#[test]
fn delete_forgets_the_steps_and_is_repeatable() {
let (parent, path) = journal_path("journal-delete");
let mut journal: Journal = Journal::load(path.as_path()).expect("load");
journal.mark("copy", "done").expect("mark");
journal.delete().expect("delete");
assert!(!path.exists() && !journal.is_persisted() && !journal.is_done("copy"));
journal.delete().expect("a second delete");
remove(parent.as_path());
}
#[test]
fn a_journal_path_that_cannot_be_removed_is_reported() {
let (parent, path) = journal_path("journal-undeletable");
std::fs::create_dir(path.as_path()).expect("directory");
std::fs::write(path.join("inside"), b"x").expect("file");
let mut journal: Journal = Journal { path: path.clone(), started: String::from("x"), states: BTreeMap::new(), times: BTreeMap::new(), persisted: true };
let error: MigrateError = journal.delete().expect_err("a directory is not a file");
assert!(error.to_string().contains("cannot delete the journal"), "{error}");
remove(parent.as_path());
}
#[test]
fn a_step_keeps_its_own_time_and_an_old_record_has_none() {
let directory = crate::test_support::fixture("journal-times");
let path = directory.join("journal.json");
let mut journal: Journal = Journal::load(path.as_path()).expect("empty");
assert_eq!(journal.seconds_of("copy"), None);
journal.mark("copy", "done").expect("mark");
let first: i64 = journal.seconds_of("copy").expect("time");
std::thread::sleep(std::time::Duration::from_millis(1100));
journal.mark("delta1", "done").expect("mark");
let reloaded: Journal = Journal::load(path.as_path()).expect("reload");
assert_eq!(reloaded.seconds_of("copy"), Some(first), "marking another step does not move this step's time");
assert!(reloaded.seconds_of("delta1") > Some(first));
std::fs::write(path.as_path(), br#"{"schema":1,"started":"x","steps":{"copy":{"state":"done","at":"y"}}}"#).expect("an older journal");
assert_eq!(Journal::load(path.as_path()).expect("load").seconds_of("copy"), None);
crate::test_support::remove(directory.as_path());
}
#[test]
fn the_start_time_is_the_recorded_one_and_a_fresh_journal_has_its_own() {
let (parent, path) = journal_path("journal-started");
let fresh: Journal = Journal::load(path.as_path()).expect("load");
let began: &str = fresh.started();
assert!(began.len() >= "2026-01-01T00:00:00Z".len() && began.contains('T') && began.ends_with('Z'), "{began}");
let recorded: &str = "2026-10-31T12:34:56.789Z";
std::fs::write(path.as_path(), format!("{{\"schema\":1,\"started\":\"{recorded}\",\"steps\":{{}}}}")).expect("a journal");
let mut loaded: Journal = Journal::load(path.as_path()).expect("load");
assert_eq!(loaded.started(), recorded);
loaded.mark("copy", "started").expect("mark");
assert_eq!(Journal::load(path.as_path()).expect("reload").started(), recorded, "a resumed run keeps the start time");
remove(parent.as_path());
}