core: the local store has tests, and two of them fail on today's code
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 33s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m43s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 19:29:33 -04:00
co-authored by Claude Opus 5.5
parent c5f93cf9f1
commit 7bc8e04518
+401
View File
@@ -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<Note> {
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<String> = 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));
}
}