Committing before copilot touches this
This commit is contained in:
commit
feefdb81b9
36 changed files with 12338 additions and 0 deletions
382
breadpad-shared/tests/store.rs
Normal file
382
breadpad-shared/tests/store.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
use breadpad_shared::store::Store;
|
||||
use breadpad_shared::types::{Note, NoteType, RecurrenceRule};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn mk() -> (TempDir, Store) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn note(body: &str, nt: NoteType) -> Note {
|
||||
Note::new(body.into(), nt, None)
|
||||
}
|
||||
|
||||
// ---- Empty state ----
|
||||
|
||||
#[test]
|
||||
fn empty_store_loads_empty_vec() {
|
||||
let (_dir, store) = mk();
|
||||
let notes = store.load_all().unwrap();
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_archive_loads_empty_vec() {
|
||||
let (_dir, store) = mk();
|
||||
let archive = store.load_archive().unwrap();
|
||||
assert!(archive.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_id_returns_none_on_empty_store() {
|
||||
let (_dir, store) = mk();
|
||||
assert!(store.get_by_id("missing").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---- save_note + load_all ----
|
||||
|
||||
#[test]
|
||||
fn save_and_load_single() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("buy milk", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, n.id);
|
||||
assert_eq!(loaded[0].body, "buy milk");
|
||||
assert_eq!(loaded[0].note_type, NoteType::Todo);
|
||||
assert!(!loaded[0].done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_three_notes_all_loaded() {
|
||||
let (_dir, store) = mk();
|
||||
let a = note("alpha", NoteType::Idea);
|
||||
let b = note("beta", NoteType::Note);
|
||||
let c = note("gamma", NoteType::Question);
|
||||
store.save_note(&a).unwrap();
|
||||
store.save_note(&b).unwrap();
|
||||
store.save_note(&c).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 3);
|
||||
let bodies: Vec<&str> = loaded.iter().map(|n| n.body.as_str()).collect();
|
||||
assert!(bodies.contains(&"alpha"));
|
||||
assert!(bodies.contains(&"beta"));
|
||||
assert!(bodies.contains(&"gamma"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_note_preserves_all_fields() {
|
||||
let (_dir, store) = mk();
|
||||
let mut n = Note::new("standup".into(), NoteType::Reminder, Some("2".into()));
|
||||
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO"));
|
||||
n.tags = vec!["work".into()];
|
||||
let t = Utc::now();
|
||||
n.time = Some(t);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.workspace, Some("2".into()));
|
||||
assert_eq!(loaded.rrule.unwrap().as_str(), "RRULE:FREQ=WEEKLY;BYDAY=MO");
|
||||
assert_eq!(loaded.tags, vec!["work"]);
|
||||
assert!(loaded.time.is_some());
|
||||
}
|
||||
|
||||
// ---- update_note ----
|
||||
|
||||
#[test]
|
||||
fn update_note_changes_body() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("original", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
let mut updated = n.clone();
|
||||
updated.body = "updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].body, "updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_note_changes_type() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("task", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
let mut updated = n.clone();
|
||||
updated.note_type = NoteType::Todo;
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.note_type, NoteType::Todo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_note_does_not_affect_other_notes() {
|
||||
let (_dir, store) = mk();
|
||||
let n1 = note("first", NoteType::Note);
|
||||
let n2 = note("second", NoteType::Todo);
|
||||
store.save_note(&n1).unwrap();
|
||||
store.save_note(&n2).unwrap();
|
||||
|
||||
let mut updated = n1.clone();
|
||||
updated.body = "first-updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let second = store.get_by_id(&n2.id).unwrap().unwrap();
|
||||
assert_eq!(second.body, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_nonexistent_id_leaves_store_intact() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("real", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut ghost = n.clone();
|
||||
ghost.id = "ghost1".into();
|
||||
ghost.body = "ghost".into();
|
||||
store.update_note(&ghost).unwrap();
|
||||
|
||||
let notes = store.load_all().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0].body, "real");
|
||||
}
|
||||
|
||||
// ---- mark_done via update ----
|
||||
|
||||
#[test]
|
||||
fn mark_done_persists_through_update() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("finish task", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut done = n.clone();
|
||||
done.mark_done();
|
||||
store.update_note(&done).unwrap();
|
||||
|
||||
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
|
||||
assert!(loaded.done);
|
||||
assert!(loaded.completed.is_some());
|
||||
}
|
||||
|
||||
// ---- delete_note ----
|
||||
|
||||
#[test]
|
||||
fn delete_removes_only_target() {
|
||||
let (_dir, store) = mk();
|
||||
let keep = note("keep", NoteType::Note);
|
||||
let del = note("delete me", NoteType::Note);
|
||||
store.save_note(&keep).unwrap();
|
||||
store.save_note(&del).unwrap();
|
||||
|
||||
store.delete_note(&del.id).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].id, keep.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_all_leaves_empty_store() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("only note", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
store.delete_note(&n.id).unwrap();
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_id_is_noop() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("real note", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
store.delete_note("no-such-id").unwrap();
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// ---- get_by_id ----
|
||||
|
||||
#[test]
|
||||
fn get_by_id_finds_correct_note() {
|
||||
let (_dir, store) = mk();
|
||||
let a = note("alpha", NoteType::Idea);
|
||||
let b = note("beta", NoteType::Idea);
|
||||
store.save_note(&a).unwrap();
|
||||
store.save_note(&b).unwrap();
|
||||
|
||||
let found = store.get_by_id(&a.id).unwrap().unwrap();
|
||||
assert_eq!(found.body, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_id_returns_none_for_missing() {
|
||||
let (_dir, store) = mk();
|
||||
store.save_note(¬e("x", NoteType::Note)).unwrap();
|
||||
assert!(store.get_by_id("nope").unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---- rotate_archive ----
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_moves_old_done_notes() {
|
||||
let (_dir, store) = mk();
|
||||
|
||||
let mut old_done = note("old task", NoteType::Todo);
|
||||
old_done.done = true;
|
||||
old_done.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&old_done).unwrap();
|
||||
|
||||
let mut recent_done = note("recent task", NoteType::Todo);
|
||||
recent_done.done = true;
|
||||
recent_done.completed = Some(Utc::now() - Duration::days(1));
|
||||
store.save_note(&recent_done).unwrap();
|
||||
|
||||
let active = note("active task", NoteType::Todo);
|
||||
store.save_note(&active).unwrap();
|
||||
|
||||
let moved = store.rotate_archive(30).unwrap();
|
||||
assert_eq!(moved, 1);
|
||||
|
||||
let remaining = store.load_all().unwrap();
|
||||
assert_eq!(remaining.len(), 2);
|
||||
let remaining_ids: Vec<&str> = remaining.iter().map(|n| n.id.as_str()).collect();
|
||||
assert!(!remaining_ids.contains(&old_done.id.as_str()), "old note should be archived");
|
||||
assert!(remaining_ids.contains(&recent_done.id.as_str()));
|
||||
assert!(remaining_ids.contains(&active.id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_writes_to_archive_file() {
|
||||
let (_dir, store) = mk();
|
||||
let mut old = note("archived task", NoteType::Todo);
|
||||
old.done = true;
|
||||
old.completed = Some(Utc::now() - Duration::days(35));
|
||||
store.save_note(&old).unwrap();
|
||||
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
let archived = store.load_archive().unwrap();
|
||||
assert_eq!(archived.len(), 1);
|
||||
assert_eq!(archived[0].id, old.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_appends_to_existing_archive() {
|
||||
let (_dir, store) = mk();
|
||||
|
||||
for i in 0..3u32 {
|
||||
let mut n = note(&format!("old {}", i), NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&n).unwrap();
|
||||
}
|
||||
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
// Add more old notes and rotate again
|
||||
for i in 3..5u32 {
|
||||
let mut n = note(&format!("old {}", i), NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(40));
|
||||
store.save_note(&n).unwrap();
|
||||
}
|
||||
store.rotate_archive(30).unwrap();
|
||||
|
||||
let archived = store.load_archive().unwrap();
|
||||
assert_eq!(archived.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_zero_when_nothing_qualifies() {
|
||||
let (_dir, store) = mk();
|
||||
let n = note("active", NoteType::Note);
|
||||
store.save_note(&n).unwrap();
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
|
||||
let (_dir, store) = mk();
|
||||
let mut n = note("old but undone", NoteType::Todo);
|
||||
n.done = false;
|
||||
// Set created to far past but not done
|
||||
n.completed = Some(Utc::now() - Duration::days(100));
|
||||
store.save_note(&n).unwrap();
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
}
|
||||
|
||||
// ---- Fault tolerance ----
|
||||
|
||||
#[test]
|
||||
fn malformed_jsonl_line_is_skipped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let notes_path = dir.path().join("notes.jsonl");
|
||||
|
||||
let valid = note("valid note", NoteType::Note);
|
||||
let valid_line = serde_json::to_string(&valid).unwrap();
|
||||
fs::write(
|
||||
¬es_path,
|
||||
format!("{}\n{{not valid json}}\n{}\n", valid_line, valid_line),
|
||||
).unwrap();
|
||||
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
let loaded = store.load_all().unwrap();
|
||||
// Two valid lines, one bad line skipped
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert!(loaded.iter().all(|n| n.body == "valid note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_in_jsonl_are_skipped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let notes_path = dir.path().join("notes.jsonl");
|
||||
|
||||
let n = note("hello", NoteType::Note);
|
||||
let line = serde_json::to_string(&n).unwrap();
|
||||
fs::write(¬es_path, format!("\n\n{}\n\n", line)).unwrap();
|
||||
|
||||
let store = Store::from_dir(dir.path()).unwrap();
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
}
|
||||
|
||||
// ---- Atomic write ----
|
||||
|
||||
#[test]
|
||||
fn no_tmp_file_left_after_update() {
|
||||
let (dir, store) = mk();
|
||||
let n = note("task", NoteType::Todo);
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
let mut updated = n.clone();
|
||||
updated.body = "updated".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let tmp = dir.path().join("notes.tmp");
|
||||
assert!(!tmp.exists(), ".tmp file should be renamed after write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_writes_atomically_via_rename() {
|
||||
// Verify the file content is consistent after an update (no partial writes visible)
|
||||
let (_dir, store) = mk();
|
||||
for i in 0..10u32 {
|
||||
store.save_note(¬e(&format!("note {}", i), NoteType::Note)).unwrap();
|
||||
}
|
||||
|
||||
let first = store.load_all().unwrap()[0].clone();
|
||||
let mut updated = first.clone();
|
||||
updated.body = "modified".into();
|
||||
store.update_note(&updated).unwrap();
|
||||
|
||||
let loaded = store.load_all().unwrap();
|
||||
assert_eq!(loaded.len(), 10);
|
||||
assert!(loaded.iter().any(|n| n.body == "modified"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue