AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
Showing only changes of commit 7bc8e04518 - Show all commits
+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));
}
}