diff --git a/core/src/local/store.rs b/core/src/local/store.rs index dddc51d..496dcba 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -988,3 +988,404 @@ pub fn rename_saved_filter( }, ) } + +#[cfg(test)] +mod tests { + //! The store had no tests of its own until two bugs shipped in it unnoticed: + //! `load_note` reading its timestamps from the wrong columns after one was + //! dropped (fa89da1), and the board's text facet binding its pattern twice for + //! one placeholder (95aa10c). Both are the kind of thing only a real query + //! against a real schema catches, which is what every test here runs. + + use super::*; + use crate::local::schema; + + fn db() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory db"); + schema::migrate(&conn).expect("migrate"); + conn + } + + fn note(conn: &Connection, body: &str) -> Note { + create_note( + conn, + &NoteCreateInput { + body: body.to_string(), + items: None, + }, + ) + .expect("create") + } + + /// A note written straight into the table, so its timestamps are exactly + /// what the test says rather than whatever `now()` returned. + fn stamped(conn: &Connection, id: &str, body: &str, created: &str, updated: &str) { + conn.execute( + "INSERT INTO notes (id, body, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + params![id, body, created, updated], + ) + .expect("insert"); + } + + fn list(conn: &Connection, query: Value) -> Vec { + let query: ListQuery = serde_json::from_value(query).expect("query shape"); + list_notes(conn, &query).expect("list") + } + + fn ids(notes: &[Note]) -> Vec<&str> { + notes.iter().map(|n| n.id.as_str()).collect() + } + + // ---- reading a note -------------------------------------------------------- + + #[test] + fn each_timestamp_comes_from_its_own_column() { + let conn = db(); + stamped( + &conn, + "n", + "body", + "2026-01-01T00:00:00.000Z", + "2026-02-02T00:00:00.000Z", + ); + conn.execute( + "UPDATE notes SET trashed = 1, trashed_at = '2026-03-03T00:00:00.000Z' WHERE id = 'n'", + [], + ) + .expect("trash"); + + let n = get_note(&conn, "n").expect("get"); + assert_eq!(n.created_at.as_deref(), Some("2026-01-01T00:00:00.000Z")); + assert_eq!(n.updated_at.as_deref(), Some("2026-02-02T00:00:00.000Z")); + assert_eq!(n.deleted_at.as_deref(), Some("2026-03-03T00:00:00.000Z")); + } + + #[test] + fn a_new_note_carries_both_timestamps_and_no_trash_time() { + let conn = db(); + let n = note(&conn, "hello"); + assert!(n.created_at.is_some(), "created_at missing"); + assert!(n.updated_at.is_some(), "updated_at missing"); + assert!(n.deleted_at.is_none()); + } + + #[test] + fn a_list_only_note_is_named_by_its_first_item() { + let conn = db(); + let n = note(&conn, "- [ ] milk\n- [ ] eggs"); + assert_eq!(n.display_title, "milk"); + assert_eq!(n.items.len(), 2); + + let gone = note(&conn, "trashed one"); + trash(&conn, &gone.id).expect("trash"); + let names: Vec = titles(&conn) + .expect("titles") + .into_iter() + .map(|t| t.title) + .collect(); + assert_eq!(names, vec!["milk".to_string()], "trash has no place in the palette"); + } + + // ---- the board query ------------------------------------------------------- + + #[test] + fn text_search_on_the_board_matches_the_body() { + let conn = db(); + let milk = note(&conn, "buy milk"); + note(&conn, "call mom"); + let found = list(&conn, json!({"view": "notes", "facets": {"q": "milk"}})); + assert_eq!(ids(&found), vec![milk.id.as_str()]); + } + + #[test] + fn text_search_combines_with_a_date_range() { + // The facets bind positionally, so a text facet that binds the wrong number + // of values shifts every bound after it — this pairs it with one. + let conn = db(); + stamped(&conn, "old", "milk", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + stamped(&conn, "new", "milk", "2026-07-01T00:00:00.000Z", "2026-07-01T00:00:00.000Z"); + stamped(&conn, "other", "eggs", "2026-07-01T00:00:00.000Z", "2026-07-01T00:00:00.000Z"); + let found = list( + &conn, + json!({"view": "notes", "facets": {"q": "milk", "created_after": "2026-06-01T00:00:00.000Z"}}), + ); + assert_eq!(ids(&found), vec!["new"]); + } + + #[test] + fn text_search_takes_like_wildcards_literally() { + let conn = db(); + let sure = note(&conn, "100% sure"); + note(&conn, "1000 things"); + let found = list(&conn, json!({"view": "notes", "facets": {"q": "100%"}})); + assert_eq!(ids(&found), vec![sure.id.as_str()]); + } + + #[test] + fn label_filters_are_anded() { + let conn = db(); + let home = create_label(&conn, "home").expect("label"); + let work = create_label(&conn, "work").expect("label"); + let both = note(&conn, "both"); + let one = note(&conn, "one"); + set_labels(&conn, &both.id, &[home.id.clone(), work.id.clone()]).expect("labels"); + set_labels(&conn, &one.id, &[home.id.clone()]).expect("labels"); + + let found = list( + &conn, + json!({"view": "notes", "labelId": home.id, "facets": {"label": [work.id]}}), + ); + assert_eq!(ids(&found), vec![both.id.as_str()]); + } + + #[test] + fn reminder_and_attachment_facets() { + let conn = db(); + let reminded = note(&conn, "reminded"); + let attached = note(&conn, "attached"); + note(&conn, "plain"); + update_note(&conn, &reminded.id, &json!({"remind_at": "2026-12-01T09:00:00.000Z"})) + .expect("remind"); + conn.execute( + "INSERT INTO attachments (id, note_id, url) VALUES ('a', ?1, '/x')", + [&attached.id], + ) + .expect("attach"); + + let r = list(&conn, json!({"view": "notes", "facets": {"has_reminder": true}})); + assert_eq!(ids(&r), vec![reminded.id.as_str()]); + let a = list(&conn, json!({"view": "notes", "facets": {"has_attachment": true}})); + assert_eq!(ids(&a), vec![attached.id.as_str()]); + } + + #[test] + fn views_split_the_board_archive_and_trash() { + let conn = db(); + let kept = note(&conn, "kept"); + let archived = note(&conn, "archived"); + let trashed = note(&conn, "trashed"); + update_note(&conn, &archived.id, &json!({"archived": true})).expect("archive"); + trash(&conn, &trashed.id).expect("trash"); + + assert_eq!(ids(&list(&conn, json!({"view": "notes"}))), vec![kept.id.as_str()]); + assert_eq!( + ids(&list(&conn, json!({"view": "archived"}))), + vec![archived.id.as_str()] + ); + assert_eq!(ids(&list(&conn, json!({"view": "trash"}))), vec![trashed.id.as_str()]); + } + + #[test] + fn pinned_first_then_position() { + let conn = db(); + let first = note(&conn, "first"); + let second = note(&conn, "second"); + // Newest is on top by position... + assert_eq!( + ids(&list(&conn, json!({"view": "notes"}))), + vec![second.id.as_str(), first.id.as_str()] + ); + // ...until the older one is pinned. + update_note(&conn, &first.id, &json!({"pinned": true})).expect("pin"); + assert_eq!( + ids(&list(&conn, json!({"view": "notes"}))), + vec![first.id.as_str(), second.id.as_str()] + ); + } + + #[test] + fn sort_created_orders_by_birth_not_by_edit() { + let conn = db(); + stamped(&conn, "older", "a", "2026-01-01T00:00:00.000Z", "2026-09-01T00:00:00.000Z"); + stamped(&conn, "newer", "b", "2026-05-01T00:00:00.000Z", "2026-05-01T00:00:00.000Z"); + let found = list(&conn, json!({"view": "notes", "sort": "created"})); + assert_eq!(ids(&found), vec!["newer", "older"]); + } + + #[test] + fn reorder_puts_the_first_id_on_top() { + let conn = db(); + let a = note(&conn, "a"); + let b = note(&conn, "b"); + reorder(&conn, &[a.id.clone(), b.id.clone()]).expect("reorder"); + assert_eq!( + ids(&list(&conn, json!({"view": "notes"}))), + vec![a.id.as_str(), b.id.as_str()] + ); + } + + // ---- #tags ----------------------------------------------------------------- + + #[test] + fn a_standalone_tag_is_lifted_into_a_manual_label() { + let conn = db(); + let n = note(&conn, "milk\n#groceries"); + assert_eq!(n.body, "milk"); + assert_eq!(n.labels.len(), 1); + assert_eq!(n.labels[0].name, "groceries"); + assert!(!n.labels[0].via_tag, "nothing in the body backs it any more"); + } + + #[test] + fn an_inline_tag_stays_and_detaches_when_its_text_goes() { + let conn = db(); + let n = note(&conn, "buy #milk today"); + assert_eq!(n.body, "buy #milk today"); + assert!(n.labels.iter().any(|l| l.name == "milk" && l.via_tag)); + + let edited = update_note(&conn, &n.id, &json!({"body": "buy today"})).expect("edit"); + assert!(edited.labels.is_empty()); + } + + // ---- writes ---------------------------------------------------------------- + + #[test] + fn only_the_first_write_of_an_editing_session_snapshots() { + let conn = db(); + let n = note(&conn, "one"); + update_note(&conn, &n.id, &json!({"body": "two"})).expect("edit"); + update_note(&conn, &n.id, &json!({"body": "three"})).expect("edit"); + let history = revisions(&conn, &n.id).expect("revisions"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].body, "one", "the note as it was found"); + } + + #[test] + fn rewriting_the_same_body_is_not_a_revision() { + let conn = db(); + let n = note(&conn, "same"); + update_note(&conn, &n.id, &json!({"body": "same"})).expect("edit"); + assert!(revisions(&conn, &n.id).expect("revisions").is_empty()); + } + + #[test] + fn restoring_a_revision_brings_back_its_body() { + let conn = db(); + let n = note(&conn, "original"); + update_note(&conn, &n.id, &json!({"body": "changed"})).expect("edit"); + let rev = revisions(&conn, &n.id).expect("revisions").remove(0); + let restored = restore_revision(&conn, &n.id, &rev.id).expect("restore"); + assert_eq!(restored.body, "original"); + } + + #[test] + fn ticking_an_item_rewrites_its_line_and_a_stale_index_does_nothing() { + let conn = db(); + let n = note(&conn, "- [ ] milk\n- [ ] eggs"); + let ticked = update_item(&conn, &n.id, "1", &json!({"checked": true})).expect("tick"); + assert_eq!(ticked.body, "- [ ] milk\n- [x] eggs"); + + let stale = update_item(&conn, &n.id, "9", &json!({"checked": true})).expect("stale"); + assert_eq!(stale.body, ticked.body); + } + + #[test] + fn trash_keeps_its_first_time_and_restore_clears_it() { + let conn = db(); + let n = note(&conn, "bin me"); + let first = trash(&conn, &n.id).expect("trash").deleted_at; + assert!(first.is_some()); + let again = trash(&conn, &n.id).expect("trash again").deleted_at; + assert_eq!(again, first, "a second trash must not restart retention"); + + let back = restore(&conn, &n.id).expect("restore"); + assert!(!back.trashed); + assert!(back.deleted_at.is_none()); + } + + #[test] + fn deleting_forever_leaves_a_tombstone_for_sync() { + let conn = db(); + let n = note(&conn, "gone"); + delete_forever(&conn, &n.id).expect("delete"); + let tombstones: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pending_deletes WHERE entity = 'note' AND id = ?1", + [&n.id], + |r| r.get(0), + ) + .expect("count"); + assert_eq!(tombstones, 1); + assert!(get_note(&conn, &n.id).is_err()); + } + + // ---- reminders ------------------------------------------------------------- + + #[test] + fn completing_a_one_off_reminder_clears_it() { + let conn = db(); + let n = note(&conn, "once"); + update_note(&conn, &n.id, &json!({"remind_at": "2026-01-01T09:00:00.000Z"})) + .expect("remind"); + let done = complete_reminder(&conn, &n.id).expect("complete"); + assert!(done.remind_at.is_none()); + assert!(done.recurrence.is_none()); + } + + #[test] + fn completing_a_recurring_reminder_moves_it_into_the_future() { + let conn = db(); + let n = note(&conn, "daily"); + update_note( + &conn, + &n.id, + &json!({"remind_at": "2026-01-01T09:00:00.000Z", "recurrence": "daily"}), + ) + .expect("remind"); + let done = complete_reminder(&conn, &n.id).expect("complete"); + let next = DateTime::parse_from_rfc3339(done.remind_at.as_deref().expect("still set")) + .expect("rfc3339"); + assert!(next.with_timezone(&Utc) > Utc::now()); + assert_eq!(done.recurrence.as_deref(), Some("daily")); + } + + #[test] + fn reminders_come_soonest_first_and_skip_the_trash() { + let conn = db(); + let later = note(&conn, "later"); + let sooner = note(&conn, "sooner"); + let binned = note(&conn, "binned"); + update_note(&conn, &later.id, &json!({"remind_at": "2026-12-02T09:00:00.000Z"})) + .expect("remind"); + update_note(&conn, &sooner.id, &json!({"remind_at": "2026-12-01T09:00:00.000Z"})) + .expect("remind"); + update_note(&conn, &binned.id, &json!({"remind_at": "2026-11-01T09:00:00.000Z"})) + .expect("remind"); + trash(&conn, &binned.id).expect("trash"); + assert_eq!( + ids(&reminders(&conn).expect("reminders")), + vec![sooner.id.as_str(), later.id.as_str()] + ); + } + + // ---- labels ---------------------------------------------------------------- + + #[test] + fn renaming_onto_an_existing_name_merges_into_the_older_label() { + let conn = db(); + let work = create_label(&conn, "work").expect("label"); + let jobs = create_label(&conn, "jobs").expect("label"); + let n = note(&conn, "task"); + set_labels(&conn, &n.id, &[jobs.id.clone()]).expect("labels"); + + let survivor = rename_label(&conn, &jobs.id, "Work").expect("rename"); + assert_eq!(survivor.id, work.id); + assert_eq!(survivor.name, "Work"); + assert_eq!(list_labels(&conn).expect("labels").len(), 1); + let labels = get_note(&conn, &n.id).expect("get").labels; + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].id, work.id, "the note follows the merge"); + } + + #[test] + fn label_counts_ignore_trashed_notes() { + let conn = db(); + let home = create_label(&conn, "home").expect("label"); + let kept = note(&conn, "kept"); + let binned = note(&conn, "binned"); + set_labels(&conn, &kept.id, &[home.id.clone()]).expect("labels"); + set_labels(&conn, &binned.id, &[home.id.clone()]).expect("labels"); + trash(&conn, &binned.id).expect("trash"); + assert_eq!(list_labels(&conn).expect("labels")[0].count, Some(1)); + } +}