From 7bc8e0451841332d05d755ffad7e9428d60c917f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 19:29:33 -0400 Subject: [PATCH] core: the local store has tests, and two of them fail on today's code store.rs had none, which is how two bugs reached desktop and Android unseen. These exercise every board facet, the timestamps, tags, revisions, items, trash, reminders and label merges against a real migrated schema. Two are expected RED on this commit, on purpose, so CI shows they catch what they were written for: - each_timestamp_comes_from_its_own_column / a_new_note_carries_both_timestamps: load_note reads created_at and updated_at one column too far right since fa89da1 dropped `color`. - text_search_*: the text facet binds its LIKE pattern twice for one `?`, left over from title+body (95aa10c). The fix follows in the next commit. Co-Authored-By: Claude Opus 5.5 --- core/src/local/store.rs | 401 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) 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)); + } +}