AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user