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 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 TestCreateRoundTripsThroughDisk(t *testing.T) { s, dir := open(t) n, err := s.Create("first thought #idea\n\nwith a second paragraph \n\n") if err != nil { t.Fatal(err) } if n.Body != "first thought #idea\n\nwith a second paragraph" { t.Fatalf("body %q", n.Body) } 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.Created.Equal(n.Created) || !got.Updated.Equal(n.Updated) || !reflect.DeepEqual(got.Tags, []string{"idea"}) { t.Fatalf("reloaded %+v, want %+v", got, n) } } func TestCreateRejectsEmptyAndHuge(t *testing.T) { s, _ := open(t) if _, err := s.Create(" \n\t"); !errors.Is(err, ErrEmpty) { t.Fatalf("empty: %v", err) } if _, err := s.Create(strings.Repeat("x", MaxBody+1)); !errors.Is(err, ErrTooLong) { t.Fatalf("huge: %v", err) } } func TestPlainMarkdownFileIsANote(t *testing.T) { s, 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"}) { t.Fatalf("%+v", n) } _ = s } func TestUpdateConflictAndPin(t *testing.T) { s, _ := open(t) n, _ := s.Create("one") body := "two" n2, err := s.Update(n.ID, &body, nil, n.Updated) if err != nil || n2.Body != "two" || !n2.Updated.After(n.Updated) { t.Fatalf("update: %v %+v", err, n2) } stale := "three" cur, err := s.Update(n.ID, &stale, nil, n.Updated) if !errors.Is(err, ErrConflict) || cur.Body != "two" { t.Fatalf("stale write: %v %+v", err, cur) } yes := true n3, err := s.Update(n.ID, nil, &yes, time.Time{}) if err != nil || !n3.Pinned || !n3.Updated.Equal(n2.Updated) { t.Fatalf("pin should not move updated: %v %+v", err, n3) } // a write without a base always goes through (scripts appending) if _, err := s.Update(n.ID, &stale, nil, time.Time{}); err != nil { t.Fatal(err) } if _, err := s.Update("nope", &stale, nil, time.Time{}); !errors.Is(err, ErrNotFound) { t.Fatalf("missing: %v", err) } } func TestTrashRestorePurge(t *testing.T) { s, dir := open(t) n, _ := s.Create("to be thrown away") if err := s.Purge(n.ID); !errors.Is(err, ErrNotTrash) { t.Fatalf("purge of a live note: %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 list, _ := s.List(Query{}); len(list) != 0 { t.Fatal("trashed note still listed") } if list, total := s.List(Query{View: "trash"}); len(list) != 1 || total != 1 { t.Fatal("trash view empty") } // survives a restart in the trash s2, _ := Open(dir) if list, _ := s2.List(Query{View: "trash"}); len(list) != 1 { t.Fatal("trash lost on reload") } if _, err := s2.Restore(n.ID); err != nil { t.Fatal(err) } if list, _ := s2.List(Query{}); len(list) != 1 { t.Fatal("restore failed") } 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 note is still there") } a, _ := s2.Create("a") b, _ := s2.Create("b") s2.Trash(a.ID) s2.Trash(b.ID) if k, err := s2.EmptyTrash(); err != nil || k != 2 { 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" { 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 note") } 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) } } 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) } } func TestSearchAndTags(t *testing.T) { s, _ := open(t) s.Create("Buy oat MILK #errands") time.Sleep(2 * time.Millisecond) s.Create("Call the dentist #errands #health") time.Sleep(2 * time.Millisecond) s.Create("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: "the dentist"}); len(list) != 1 { t.Fatalf("all words must match: %d", len(list)) } if list, _ := s.List(Query{Text: "milk dentist"}); len(list) != 0 { t.Fatalf("words in different notes: %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 || !strings.HasPrefix(all[0].Body, "Idea") { t.Fatalf("newest first with a page size: %d of %d, first %q", len(all), total, all[0].Body) } if page2, _ := s.List(Query{Limit: 2, Offset: 2}); len(page2) != 1 { t.Fatalf("second page: %d", len(page2)) } counts, tags := s.Summary() if counts.All != 3 || tags[0].Tag != "errands" || tags[0].Count != 2 { t.Fatalf("summary %+v %+v", counts, tags) } } 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) } } s, 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") } _ = s } func TestZip(t *testing.T) { s, _ := open(t) a, _ := s.Create("keep me") b, _ := s.Create("bin me") s.Trash(b.ID) 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) } if len(zr.File) != 1 || zr.File[0].Name != "basking/"+a.ID+".md" { t.Fatalf("zip has %d files", len(zr.File)) } }