use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::{Command, Output};
fn note(home: &tempfile::TempDir) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_note"));
command
.env("NOTE_TO_SELF_HOME", home.path())
.env_remove("NOTE_TO_SELF_PASSWORD")
.env_remove("JRNL2_PASSWORD")
.env_remove("NOTE_TO_SELF_JOURNAL_PASSWORD")
.env_remove("NOTE_TO_SELF_LOCK_PASSWORD");
command
}
fn assert_success(output: Output) -> String {
if !output.status.success() {
panic!(
"command failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
String::from_utf8(output.stdout).expect("stdout should be utf-8")
}
fn empty_editor(home: &tempfile::TempDir) -> std::path::PathBuf {
let path = home.path().join("empty-editor.sh");
fs::write(&path, "#!/usr/bin/env sh\nexit 0\n").unwrap();
let mut permissions = fs::metadata(&path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&path, permissions).unwrap();
path
}
fn sample_png(home: &tempfile::TempDir) -> std::path::PathBuf {
let path = home.path().join("pixel.png");
fs::write(
&path,
[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
0x44, 0x52,
],
)
.unwrap();
path
}
struct TestServer {
base_url: String,
_task: tokio::task::JoinHandle<()>,
}
async fn spawn_sync_server() -> TestServer {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let app = note_to_self_server::app_with_bucket(note_to_self_server::bucket::Bucket::mock_s3(
note_to_self_server::bucket::MockS3::new(),
))
.await
.unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base_url: format!("http://{address}"),
_task: task,
}
}
fn login(home: &tempfile::TempDir, server: &TestServer, username: &str, password: &str) {
assert_success(
note(home)
.env("NOTE_TO_SELF_PASSWORD", password)
.args([
"login",
"--server",
&server.base_url,
"--username",
username,
])
.output()
.unwrap(),
);
}
fn entry_files(home: &tempfile::TempDir, journal: &str) -> Vec<PathBuf> {
let dir = home.path().join("journals").join(journal).join("entries");
let mut files = fs::read_dir(&dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("bin"))
.collect::<Vec<_>>();
files.sort();
files
}
#[test]
fn journal_default_without_name_prints_current_default() {
let home = tempfile::tempdir().unwrap();
assert_success(
note(&home)
.args([
"init",
"--local",
"--username",
"alice",
"--journal",
"work",
])
.output()
.unwrap(),
);
let stdout = assert_success(note(&home).args(["journal", "default"]).output().unwrap());
assert_eq!(stdout.trim(), "work");
}
#[test]
fn empty_editor_for_default_new_note_exits_without_error() {
let home = tempfile::tempdir().unwrap();
assert_success(
note(&home)
.args([
"init",
"--local",
"--username",
"alice",
"--journal",
"personal",
])
.output()
.unwrap(),
);
let editor = empty_editor(&home);
let mut create = note(&home);
create
.env("EDITOR", editor)
.env("NOTE_TO_SELF_PASSWORD", "correct horse");
let stdout = assert_success(create.output().unwrap());
assert_eq!(stdout.trim(), "");
let stdout = assert_success(note(&home).args(["list"]).output().unwrap());
assert_eq!(stdout.trim(), "");
}
#[test]
fn account_password_is_cached_after_first_use() {
let home = tempfile::tempdir().unwrap();
assert_success(
note(&home)
.args([
"init",
"--local",
"--username",
"alice",
"--journal",
"personal",
])
.output()
.unwrap(),
);
let mut create = note(&home);
create.env("NOTE_TO_SELF_PASSWORD", "correct horse");
assert_success(create.args(["cached account key entry"]).output().unwrap());
let stdout = assert_success(note(&home).args(["list"]).output().unwrap());
assert!(stdout.contains("cached account key entry"));
}
#[test]
fn image_upload_creates_entry_with_encrypted_attachment() {
let home = tempfile::tempdir().unwrap();
let image = sample_png(&home);
assert_success(
note(&home)
.args([
"init",
"--local",
"--username",
"alice",
"--journal",
"personal",
])
.output()
.unwrap(),
);
let mut create = note(&home);
create.env("NOTE_TO_SELF_PASSWORD", "correct horse");
let stdout = assert_success(
create
.args(["--img", image.to_str().unwrap(), "trip", "photo"])
.output()
.unwrap(),
);
assert!(stdout.contains("created"));
assert!(stdout.contains("[image: pixel.png"));
let stdout = assert_success(note(&home).args(["list"]).output().unwrap());
assert!(stdout.contains("trip photo"));
assert!(stdout.contains("[image: pixel.png"));
let export = assert_success(note(&home).args(["export"]).output().unwrap());
assert!(export.contains("\"attachments\""));
assert!(export.contains("\"file_name\": \"pixel.png\""));
assert!(export.contains("note-image:"));
}
#[test]
fn todo_text_creates_todo_with_metadata_without_subcommands() {
let home = tempfile::tempdir().unwrap();
assert_success(
note(&home)
.args([
"init",
"--local",
"--username",
"alice",
"--journal",
"personal",
])
.output()
.unwrap(),
);
let mut create = note(&home);
create.env("NOTE_TO_SELF_PASSWORD", "correct horse");
let stdout = assert_success(
create
.args([
"todo",
"--priority",
"high",
"--due",
"2026-06-01",
"renew",
"passport",
"#errand",
])
.output()
.unwrap(),
);
assert!(stdout.contains("created todo"));
let stdout = assert_success(note(&home).args(["search", "passport"]).output().unwrap());
assert!(stdout.contains("[ ]"));
assert!(stdout.contains("p:high"));
assert!(stdout.contains("due:2026-06-01"));
assert!(stdout.contains("renew passport"));
assert!(stdout.contains("#errand"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fresh_online_client_hydrates_remote_journals_and_entries() {
let server = spawn_sync_server().await;
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
login(&first, &server, "alice", "correct horse");
assert_success(
note(&first)
.args(["first personal entry"])
.output()
.unwrap(),
);
assert_success(
note(&first)
.args(["journal", "new", "work", "--plain"])
.output()
.unwrap(),
);
assert_success(
note(&first)
.args(["-j", "work", "first work entry"])
.output()
.unwrap(),
);
login(&second, &server, "alice", "correct horse");
let journals = assert_success(note(&second).args(["journal", "list"]).output().unwrap());
assert!(journals.contains("* personal"), "{journals}");
assert!(journals.contains(" work"), "{journals}");
let personal = assert_success(note(&second).args(["list"]).output().unwrap());
assert!(personal.contains("first personal entry"), "{personal}");
let work = assert_success(note(&second).args(["-j", "work", "list"]).output().unwrap());
assert!(work.contains("first work entry"), "{work}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fresh_online_client_hydrates_locked_journal_metadata_before_syncing_it() {
let server = spawn_sync_server().await;
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
login(&first, &server, "alice", "correct horse");
assert_success(
note(&first)
.env("NOTE_TO_SELF_JOURNAL_PASSWORD", "journal secret")
.args(["journal", "new", "secret", "--locked"])
.output()
.unwrap(),
);
assert_success(
note(&first)
.env("NOTE_TO_SELF_JOURNAL_PASSWORD", "journal secret")
.args(["-j", "secret", "locked machine entry"])
.output()
.unwrap(),
);
login(&second, &server, "alice", "correct horse");
let journals = assert_success(note(&second).args(["journal", "list"]).output().unwrap());
assert!(journals.contains("secret [locked]"), "{journals}");
let secret = assert_success(
note(&second)
.env("NOTE_TO_SELF_JOURNAL_PASSWORD", "journal secret")
.args(["-j", "secret", "list"])
.output()
.unwrap(),
);
assert!(secret.contains("locked machine entry"), "{secret}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn command_refreshes_stale_locked_journal_metadata_before_opening_store() {
let server = spawn_sync_server().await;
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
login(&first, &server, "alice", "correct horse");
assert_success(
note(&first)
.env("NOTE_TO_SELF_JOURNAL_PASSWORD", "journal secret")
.args(["journal", "new", "secret", "--locked"])
.output()
.unwrap(),
);
assert_success(
note(&first)
.env("NOTE_TO_SELF_JOURNAL_PASSWORD", "journal secret")
.args(["-j", "secret", "locked entry"])
.output()
.unwrap(),
);
login(&second, &server, "alice", "correct horse");
let initial = assert_success(
note(&second)
.env("NOTE_TO_SELF_JOURNAL_PASSWORD", "journal secret")
.args(["-j", "secret", "list"])
.output()
.unwrap(),
);
assert!(initial.contains("locked entry"), "{initial}");
fs::write(
second.path().join("journals/secret/metadata.json"),
"{\n \"locked\": false,\n \"verifier\": null\n}\n",
)
.unwrap();
let refreshed = assert_success(
note(&second)
.env("NOTE_TO_SELF_JOURNAL_PASSWORD", "journal secret")
.args(["-j", "secret", "list"])
.output()
.unwrap(),
);
assert!(refreshed.contains("locked entry"), "{refreshed}");
let metadata = fs::read_to_string(second.path().join("journals/secret/metadata.json")).unwrap();
assert!(metadata.contains("\"locked\": true"), "{metadata}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sync_recovers_missing_local_entry_files_despite_stale_manifest() {
let server = spawn_sync_server().await;
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
login(&first, &server, "alice", "correct horse");
assert_success(
note(&first)
.args(["entry that must come back"])
.output()
.unwrap(),
);
login(&second, &server, "alice", "correct horse");
let initial = assert_success(note(&second).args(["list"]).output().unwrap());
assert!(initial.contains("entry that must come back"), "{initial}");
assert!(second
.path()
.join("journals/personal/sync/manifest.json")
.exists());
let files = entry_files(&second, "personal");
assert_eq!(files.len(), 1);
fs::remove_file(&files[0]).unwrap();
let recovered = assert_success(note(&second).args(["list"]).output().unwrap());
assert!(
recovered.contains("entry that must come back"),
"{recovered}"
);
assert_eq!(entry_files(&second, "personal").len(), 1);
}