package todo import ( "context" "errors" "path/filepath" "strings" "testing" ) func open(t *testing.T) *Store { t.Helper() s, err := Open(context.Background(), filepath.Join(t.TempDir(), "todo.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { s.Close() }) return s } func titles(t *testing.T, s *Store, k ListKey) string { t.Helper() items, err := s.List(context.Background(), k) if err != nil { t.Fatal(err) } var b strings.Builder for _, it := range items { if it.Completed { b.WriteString("x:") } b.WriteString(it.Title + ";") } return b.String() } func TestAddTrimsAndSkipsBlank(t *testing.T) { s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) for _, title := range []string{" one ", " ", "", "two"} { if err := s.Add(ctx, k, title); err != nil { t.Fatal(err) } } if got := titles(t, s, k); got != "one;two;" { t.Fatalf("got %q", got) } } func TestToggleAllAndClear(t *testing.T) { s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) for _, title := range []string{"a", "b", "c"} { s.Add(ctx, k, title) } items, _ := s.List(ctx, k) if err := s.Toggle(ctx, k, items[0].ID); err != nil { t.Fatal(err) } s.ToggleAll(ctx, k) // some active: all become completed if got := titles(t, s, k); got != "x:a;x:b;x:c;" { t.Fatalf("after first toggle-all: %q", got) } s.ToggleAll(ctx, k) // all completed: all become active if got := titles(t, s, k); got != "a;b;c;" { t.Fatalf("after second toggle-all: %q", got) } s.Toggle(ctx, k, items[1].ID) s.ClearCompleted(ctx, k) if got := titles(t, s, k); got != "a;c;" { t.Fatalf("after clear: %q", got) } } func TestRenameEmptyDeletes(t *testing.T) { s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) s.Add(ctx, k, "a") s.Add(ctx, k, "b") items, _ := s.List(ctx, k) if err := s.Rename(ctx, k, items[0].ID, " renamed "); err != nil { t.Fatal(err) } if err := s.Rename(ctx, k, items[1].ID, " "); err != nil { t.Fatal(err) } if got := titles(t, s, k); got != "renamed;" { t.Fatalf("got %q", got) } } // Another list's id must look exactly like a missing one, and change nothing. func TestListsAreIsolated(t *testing.T) { s, ctx := open(t), context.Background() mine, theirs := KeyFor([]byte("mine")), KeyFor([]byte("theirs")) s.Add(ctx, mine, "private") items, _ := s.List(ctx, mine) id := items[0].ID for name, err := range map[string]error{ "toggle": s.Toggle(ctx, theirs, id), "rename": s.Rename(ctx, theirs, id, "hacked"), "empty": s.Rename(ctx, theirs, id, ""), "delete": s.Delete(ctx, theirs, id), } { if !errors.Is(err, ErrNotFound) { t.Errorf("%s: got %v, want ErrNotFound", name, err) } } s.ToggleAll(ctx, theirs) s.ClearCompleted(ctx, theirs) if got := titles(t, s, mine); got != "private;" { t.Fatalf("my list changed: %q", got) } if got := titles(t, s, theirs); got != "" { t.Fatalf("their list sees: %q", got) } } func TestKeepsUnicodeAndLongTitles(t *testing.T) { s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) long := strings.Repeat("x", 10_000) s.Add(ctx, k, "naïve 🚀 日本語") s.Add(ctx, k, long) items, _ := s.List(ctx, k) if items[0].Title != "naïve 🚀 日本語" || items[1].Title != long { t.Fatalf("titles changed: %q, %d chars", items[0].Title, len(items[1].Title)) } } // Reopening an existing database runs no migration twice and keeps data. func TestReopenKeepsData(t *testing.T) { path := filepath.Join(t.TempDir(), "todo.db") ctx, k := context.Background(), KeyFor([]byte("a")) s, err := Open(ctx, path) if err != nil { t.Fatal(err) } s.Add(ctx, k, "kept") s.Close() s, err = Open(ctx, path) if err != nil { t.Fatal(err) } defer s.Close() if got := titles(t, s, k); got != "kept;" { t.Fatalf("got %q", got) } var mode string s.r.QueryRow(`PRAGMA journal_mode`).Scan(&mode) if mode != "wal" { t.Fatalf("journal_mode = %q, want wal", mode) } } // A file an earlier build left behind, with its own tables and // user_version, opens cleanly and keeps that data untouched. func TestOpensBesideForeignTables(t *testing.T) { path := filepath.Join(t.TempDir(), "todo.db") ctx := context.Background() s, err := Open(ctx, path) if err != nil { t.Fatal(err) } for _, q := range []string{ `DROP TABLE todo_items`, `DROP TABLE todo_lists`, `DROP TABLE todo_migrations`, `CREATE TABLE todos (id INTEGER PRIMARY KEY, text TEXT)`, `INSERT INTO todos (text) VALUES ('old')`, `PRAGMA user_version = 7`, } { if _, err := s.w.Exec(q); err != nil { t.Fatal(err) } } s.Close() s, err = Open(ctx, path) if err != nil { t.Fatal(err) } defer s.Close() k := KeyFor([]byte("a")) s.Add(ctx, k, "new") if got := titles(t, s, k); got != "new;" { t.Fatalf("got %q", got) } var old string s.r.QueryRow(`SELECT text FROM todos`).Scan(&old) if old != "old" { t.Fatalf("earlier data lost: %q", old) } } func TestOnlyIssuedListsExist(t *testing.T) { s, ctx := open(t), context.Background() k := KeyFor([]byte("issued")) if ok, _ := s.ListExists(ctx, k); ok { t.Fatal("unissued list exists") } if err := s.CreateList(ctx, k); err != nil { t.Fatal(err) } if ok, _ := s.ListExists(ctx, k); !ok { t.Fatal("issued list missing") } } // Lists that already held todos before the lists table existed keep working. func TestListsMigrationKeepsExistingLists(t *testing.T) { path := filepath.Join(t.TempDir(), "todo.db") ctx, k := context.Background(), KeyFor([]byte("old visitor")) s, err := Open(ctx, path) if err != nil { t.Fatal(err) } s.Add(ctx, k, "kept") for _, q := range []string{`DROP TABLE todo_lists`, `DELETE FROM todo_migrations WHERE version = 2`} { if _, err := s.w.Exec(q); err != nil { t.Fatal(err) } } s.Close() s, err = Open(ctx, path) if err != nil { t.Fatal(err) } defer s.Close() if ok, _ := s.ListExists(ctx, k); !ok { t.Fatal("existing list not carried into todo_lists") } }