Files
Mako/internal/store/store_test.go
T
VeillaxandClaude Sonnet 5.5 b548faa258 Basking: a place to dump thoughts, jot notes and keep a journal
Plain markdown files, tags by typing #hashtags, a page per day, search,
pin and trash with undo, autosave with conflict warnings, API tokens,
zip export, and the Lightwell dashboard. Includes install.sh, CI and
release workflows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:16:33 -06:00

274 lines
7.6 KiB
Go

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))
}
}