package store import ( "archive/zip" "bytes" "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" ) func open(t *testing.T) (*Store, string) { t.Helper() dir := t.TempDir() s, err := Open(dir) if err != nil { t.Fatal(err) } return s, dir } func mk(t *testing.T, s *Store, title, body string) Note { t.Helper() n, err := s.Create(NewNote{Title: title, Body: body}) if err != nil { t.Fatal(err) } return n } func str(s string) *string { return &s } func TestTags(t *testing.T) { cases := []struct { in string want []string }{ {"buy milk #errands #Errands and #home/garden", []string{"errands", "home/garden"}}, {"issue #12 is not a tag, nor is a#b", []string{}}, {"# Heading is not a tag\n#idea at the start", []string{"idea"}}, {"`#code` and\n```\n#fenced\n```\n#real", []string{"real"}}, {"(#paren) and #trailing-", []string{"paren", "trailing"}}, {"#ünï and #日本語", []string{"ünï", "日本語"}}, } for _, c := range cases { if got := Tags(c.in); !reflect.DeepEqual(got, c.want) { t.Errorf("Tags(%q) = %v, want %v", c.in, got, c.want) } } } func TestLinks(t *testing.T) { got := Links("see [[Trip Plan]] and [[trip plan|the plan]] and [[ Ideas ]], not `[[code]]` or\n```\n[[fenced]]\n```\n[[]] [[a|b]]") want := []string{"trip plan", "ideas", "a"} if !reflect.DeepEqual(got, want) { t.Fatalf("Links = %v, want %v", got, want) } } func TestNameComesFromTitleOrFirstLine(t *testing.T) { s, _ := open(t) cases := []struct{ title, body, want string }{ {"Trip", "ignored", "Trip"}, {"", "# Heading line\nmore", "Heading line"}, {"", "- [ ] buy **milk** [[Shop]]", "buy milk Shop"}, {"", "\n\n plain \n", "plain"}, {"", strings.Repeat("word ", 30), strings.TrimSpace(strings.Repeat("word ", 30)[:60]) + "..."}, } for _, c := range cases { n := mk(t, s, c.title, c.body) if n.Name != c.want { t.Errorf("name of %q/%q = %q, want %q", c.title, c.body, n.Name, c.want) } } if _, err := s.Create(NewNote{}); !errors.Is(err, ErrEmpty) { t.Fatalf("empty page: %v", err) } only := mk(t, s, "Just a title", "") if only.Name != "Just a title" { t.Fatal("a page can be only a title") } } func TestCreateRoundTripsThroughDisk(t *testing.T) { s, dir := open(t) parent := mk(t, s, "Projects", "") n, err := s.Create(NewNote{Title: " Basking: a \"notebook\" [x]|y ", Body: "first thought #idea\n\nwith a second paragraph \n\n", Parent: parent.ID}) if err != nil { t.Fatal(err) } if n.Body != "first thought #idea\n\nwith a second paragraph" || n.Title != `Basking: a "notebook" xy` { t.Fatalf("clean-up: %q %q", n.Body, n.Title) } if !ValidID(n.ID) { t.Fatalf("id %q", n.ID) } if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil { t.Fatal(err) } s2, err := Open(dir) if err != nil { t.Fatal(err) } got, err := s2.Get(n.ID) if err != nil { t.Fatal(err) } if got.Body != n.Body || got.Title != n.Title || got.Parent != parent.ID || !got.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) { t.Fatalf("reloaded %+v, want %+v", got, n) } } func TestCreateRejectsHuge(t *testing.T) { s, _ := open(t) if _, err := s.Create(NewNote{Body: strings.Repeat("x", MaxBody+1)}); !errors.Is(err, ErrTooLong) { t.Fatalf("huge: %v", err) } } func TestPlainMarkdownFileIsAPage(t *testing.T) { _, dir := open(t) path := filepath.Join(dir, "notes", "from-elsewhere.md") os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644) mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC) os.Chtimes(path, mod, mod) s2, err := Open(dir) if err != nil { t.Fatal(err) } n, err := s2.Get("from-elsewhere") if err != nil { t.Fatal(err) } if n.Body != "# Dropped in\nby hand #manual" || !n.Created.Equal(mod) || !reflect.DeepEqual(n.Tags, []string{"manual"}) || n.Name != "Dropped in" { t.Fatalf("%+v", n) } } func TestUpdateConflictAndPin(t *testing.T) { s, _ := open(t) n := mk(t, s, "", "one") n2, err := s.Update(n.ID, Change{Body: str("two")}, n.Updated) if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) { t.Fatalf("update: %v %+v", err, n2) } cur, err := s.Update(n.ID, Change{Body: str("three")}, n.Updated) if !errors.Is(err, ErrConflict) || cur.Body != "two" { t.Fatalf("stale write: %v %+v", err, cur) } if _, err := s.Update(n.ID, Change{Title: str("Late title")}, n.Updated); !errors.Is(err, ErrConflict) { t.Fatalf("a stale title should conflict too: %v", err) } yes := true n3, err := s.Update(n.ID, Change{Pinned: &yes}, time.Time{}) if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) { t.Fatalf("pin should not move updated: %v %+v", err, n3) } if _, err := s.Update(n.ID, Change{Body: str("four")}, time.Time{}); err != nil { t.Fatal(err) // a write without a base always goes through } if _, err := s.Update("nope", Change{Body: str("x")}, time.Time{}); !errors.Is(err, ErrNotFound) { t.Fatalf("missing: %v", err) } } func TestTitlesAreUnique(t *testing.T) { s, _ := open(t) a := mk(t, s, "Trip Plan", "") if _, err := s.Create(NewNote{Title: "trip plan"}); !errors.Is(err, ErrTitleTaken) { t.Fatalf("same title, other case: %v", err) } b := mk(t, s, "Other", "") if _, err := s.Update(b.ID, Change{Title: str("TRIP PLAN")}, time.Time{}); !errors.Is(err, ErrTitleTaken) { t.Fatalf("rename onto a taken title: %v", err) } if got, _ := s.Get(b.ID); got.Title != "Other" { t.Fatal("a refused rename must change nothing") } if _, err := s.Update(a.ID, Change{Title: str("trip plan")}, time.Time{}); err != nil { t.Fatalf("a page may change the case of its own title: %v", err) } if _, err := s.Create(NewNote{Title: "2026-10-31"}); !errors.Is(err, ErrTitleTaken) { t.Fatalf("dates belong to the journal: %v", err) } if _, err := s.Update(b.ID, Change{Title: str("")}, time.Time{}); err != nil { t.Fatalf("clearing a title: %v", err) } } func TestRenameRewritesLinks(t *testing.T) { s, _ := open(t) target := mk(t, s, "Trip Plan", "details") a := mk(t, s, "", "see [[Trip Plan]] and [[trip plan|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Trip Plan]]`") b := mk(t, s, "", "nothing here, [[Elsewhere]]") if _, err := s.Update(target.ID, Change{Title: str("Lisbon Trip")}, time.Time{}); err != nil { t.Fatal(err) } got, _ := s.Get(a.ID) want := "see [[Lisbon Trip]] and [[Lisbon Trip|the plan]]\n```\n[[Trip Plan]]\n```\nand `[[Lisbon Trip]]`" if got.Body != want { t.Fatalf("body:\n%s\nwant:\n%s", got.Body, want) } if !got.Updated.After(a.Updated) { t.Fatal("a rewritten page should move forward so open editors notice") } if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) { t.Fatal("unrelated pages must not be touched") } s2, _ := Open(s.dir) if again, _ := s2.Get(a.ID); again.Body != want { t.Fatal("rewrite was not saved") } } func TestBacklinks(t *testing.T) { s, _ := open(t) target := mk(t, s, "Ideas", "") mk(t, s, "Zeta", "mentions [[ideas]]") mk(t, s, "Alpha", "also [[Ideas|my ideas]]") mk(t, s, "Nope", "[[Something else]]") gone := mk(t, s, "Deleted", "[[Ideas]]") s.Trash(gone.ID) got := s.Backlinks(target.ID) if len(got) != 2 || got[0].Name != "Alpha" || got[1].Name != "Zeta" { t.Fatalf("backlinks %+v", got) } if b := s.Backlinks(mk(t, s, "", "no title").ID); len(b) != 0 { t.Fatal("an untitled page cannot be linked to") } } func TestTreeParentsAndCycles(t *testing.T) { s, dir := open(t) a := mk(t, s, "A", "") b, _ := s.Create(NewNote{Title: "B", Parent: a.ID}) c, _ := s.Create(NewNote{Title: "C", Parent: b.ID}) if _, err := s.Update(a.ID, Change{Parent: &c.ID}, time.Time{}); !errors.Is(err, ErrBadParent) { t.Fatalf("a page inside its own descendant: %v", err) } if _, err := s.Update(a.ID, Change{Parent: &a.ID}, time.Time{}); !errors.Is(err, ErrBadParent) { t.Fatalf("a page inside itself: %v", err) } if _, err := s.Create(NewNote{Title: "D", Parent: "nope"}); !errors.Is(err, ErrBadParent) { t.Fatalf("a missing parent: %v", err) } root := "" if got, err := s.Update(c.ID, Change{Parent: &root}, time.Time{}); err != nil || got.Parent != "" { t.Fatalf("moving to the top level: %v %+v", err, got) } // a hand-edited file with a circle of parents is mended on load os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644) os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644) os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644) s2, err := Open(dir) if err != nil { t.Fatal(err) } for _, id := range []string{"x1", "x2", "x3"} { n, _ := s2.Get(id) if id == "x3" && n.Parent != "" { t.Errorf("%s keeps a parent that does not exist", id) } } x1, _ := s2.Get("x1") x2, _ := s2.Get("x2") if x1.Parent != "" && x2.Parent != "" { t.Error("a circle of parents must be broken") } } func TestTrashRestorePurge(t *testing.T) { s, dir := open(t) parent := mk(t, s, "Parent", "") n, _ := s.Create(NewNote{Title: "Child", Body: "to be thrown away", Parent: parent.ID}) kid, _ := s.Create(NewNote{Title: "Grandchild", Parent: n.ID}) if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) { t.Fatalf("purge of a live page: %v", err) } if _, err := s.Trash(n.ID); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dir, "trash", n.ID+".md")); err != nil { t.Fatal("not moved to trash:", err) } if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err == nil { t.Fatal("the live copy should be gone") } if got, _ := s.Get(kid.ID); got.Parent != parent.ID { t.Fatalf("pages inside a trashed page move up a level, got parent %q", got.Parent) } if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 { t.Fatal("trash view empty") } // a new page takes the title while it is away; restoring must not make two if _, err := s.Create(NewNote{Title: "Child"}); err != nil { t.Fatal(err) } s2, _ := Open(dir) back, err := s2.Restore(n.ID) if err != nil || back.Title != "Child 2" || back.Parent != parent.ID { t.Fatalf("restore: %v %+v", err, back) } s2.Trash(parent.ID) s2.Trash(n.ID) again, _ := s2.Restore(n.ID) if again.Parent != "" { t.Fatal("restoring under a parent that is gone should go to the top level") } s2.Trash(n.ID) if err := s2.Purge(n.ID); err != nil { t.Fatal(err) } if _, err := s2.Get(n.ID); !errors.Is(err, ErrNotFound) { t.Fatal("purged page is still there") } if k, err := s2.EmptyTrash(); err != nil || k != 1 { t.Fatalf("empty trash: %d %v", k, err) } } func TestDaily(t *testing.T) { s, _ := open(t) d1, err := s.Daily("2026-10-31") if err != nil || d1.ID != "daily-2026-10-31" || d1.Daily != "2026-10-31" || d1.Title != "2026-10-31" { t.Fatalf("%v %+v", err, d1) } d2, _ := s.Daily("2026-10-31") if d1.ID != d2.ID || !d1.Created.Equal(d2.Created) { t.Fatal("asking twice should give the same page") } for _, bad := range []string{"", "2026-13-01", "../etc/passwd", "today"} { if _, err := s.Daily(bad); !errors.Is(err, ErrBadDate) { t.Errorf("%q: %v", bad, err) } } if _, err := s.Update(d1.ID, Change{Title: str("Renamed")}, time.Time{}); !errors.Is(err, ErrDailyTitle) { t.Fatalf("renaming a day: %v", err) } s.Trash(d1.ID) d3, _ := s.Daily("2026-10-31") if d3.Trashed { t.Fatal("asking for a trashed day should bring it back") } s.Daily("2026-10-30") list, _ := s.List(Query{View: "daily"}) if len(list) != 2 || list[0].Daily != "2026-10-31" { t.Fatalf("daily view order: %+v", list) } linker := mk(t, s, "", "on [[2026-10-31]] I wrote") if got := s.Backlinks(d1.ID); len(got) != 1 || got[0].ID != linker.ID { t.Fatalf("a day can be linked by its date: %+v", got) } // reload keeps the title from the date, and an empty day stays out of navigation s2, _ := Open(s.dir) if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" { t.Fatalf("title after reload: %q", n.Title) } for _, r := range s2.Pages() { if r.Daily != "" { t.Fatalf("an empty day should not be listed: %+v", r) } } } func TestSearchAndTags(t *testing.T) { s, _ := open(t) s.Create(NewNote{Body: "Buy oat MILK #errands"}) time.Sleep(2 * time.Millisecond) s.Create(NewNote{Body: "Call the dentist #errands #health"}) time.Sleep(2 * time.Millisecond) s.Create(NewNote{Title: "Roadmap", Body: "Idea: a #projects/basking dashboard"}) if list, _ := s.List(Query{Text: "milk"}); len(list) != 1 { t.Fatalf("case-insensitive search: %d", len(list)) } if list, _ := s.List(Query{Text: "roadmap"}); len(list) != 1 { t.Fatalf("titles are searched: %d", len(list)) } if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 { t.Fatalf("words in different pages: %d", len(list)) } if list, _ := s.List(Query{Tag: "errands"}); len(list) != 2 { t.Fatalf("tag filter: %d", len(list)) } if list, _ := s.List(Query{Text: "#errands #health"}); len(list) != 1 { t.Fatalf("two tags in the query: %d", len(list)) } if list, _ := s.List(Query{Tag: "projects"}); len(list) != 1 { t.Fatalf("a parent tag finds its children: %d", len(list)) } all, total := s.List(Query{Limit: 2}) if len(all) != 2 || total != 3 || all[0].Title != "Roadmap" { t.Fatalf("newest first with a page size: %d of %d", len(all), total) } counts, tags := s.Summary() if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 { t.Fatalf("summary %+v %+v", counts, tags) } } func TestFind(t *testing.T) { s, _ := open(t) mk(t, s, "Trip to Lisbon", "flights and hotels") time.Sleep(2 * time.Millisecond) mk(t, s, "Packing list", "passport, charger\nlisbon weather is warm") time.Sleep(2 * time.Millisecond) mk(t, s, "Lis", "") time.Sleep(2 * time.Millisecond) mk(t, s, "Unrelated", "nothing") hits := s.Find("lis", 10) var names []string for _, h := range hits { names = append(names, h.Name) } if !reflect.DeepEqual(names, []string{"Lis", "Packing list", "Trip to Lisbon"}) { t.Fatalf("exact title first, then newer titles ahead of older: %v", names) } body := s.Find("warm", 10) if len(body) != 1 || body[0].Name != "Packing list" || body[0].Snippet != "lisbon weather is warm" { t.Fatalf("body match: %+v", body) } if recent := s.Find("", 2); len(recent) != 2 || recent[0].Name != "Unrelated" { t.Fatalf("no query lists recent pages: %+v", recent) } } func TestPagesListsWhatBelongsInNavigation(t *testing.T) { s, _ := open(t) titled := mk(t, s, "Titled", "") quick := mk(t, s, "", "a quick thought") holder := mk(t, s, "", "untitled but holds a page") child, _ := s.Create(NewNote{Body: "child", Parent: holder.ID}) pinned := mk(t, s, "", "pinned thought") yes := true s.Update(pinned.ID, Change{Pinned: &yes}, time.Time{}) gone := mk(t, s, "Gone", "") s.Trash(gone.ID) ids := map[string]bool{} for _, r := range s.Pages() { ids[r.ID] = true } for _, id := range []string{titled.ID, holder.ID, child.ID, pinned.ID} { if !ids[id] { t.Errorf("%s should be listed", id) } } for _, id := range []string{quick.ID, gone.ID} { if ids[id] { t.Errorf("%s should not be listed", id) } } } func TestIDsCannotEscape(t *testing.T) { for _, id := range []string{"", "..", "../x", "a/b", `a\b`, "A", ".hidden", strings.Repeat("a", 65)} { if ValidID(id) { t.Errorf("%q should not be a valid id", id) } } _, dir := open(t) os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644) s2, _ := Open(dir) if list, _ := s2.List(Query{}); len(list) != 0 { t.Fatal("a file whose name is not a valid id should be ignored") } } func TestZipIsAnObsidianVault(t *testing.T) { s, _ := open(t) mk(t, s, "Trip: Lisbon/Porto", "see [[Packing]]") mk(t, s, "Packing", "") mk(t, s, "", "# A quick one") mk(t, s, "", "# A quick one") bin := mk(t, s, "Bin me", "") s.Trash(bin.ID) s.Daily("2026-10-31") var buf bytes.Buffer if err := s.WriteZip(&buf); err != nil { t.Fatal(err) } zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) if err != nil { t.Fatal(err) } var names []string for _, f := range zr.File { names = append(names, f.Name) } want := map[string]bool{"basking/Trip- Lisbon-Porto.md": true, "basking/Packing.md": true, "basking/A quick one.md": true, "basking/A quick one 2.md": true, "basking/Journal/2026-10-31.md": true} if len(names) != len(want) { t.Fatalf("zip has %v", names) } for _, n := range names { if !want[n] { t.Errorf("unexpected %q in %v", n, names) } } }