Files
notas-online/internal/store/store_test.go
T
lgcosta 7e89207b70 feat: layout mobile, abas múltiplas e categorias em árvore
- Responsivo ≤760px: sidebar vira gaveta com backdrop, painel de versões em tela
  cheia, toolbar/menu rolam sem rótulos, alvos de toque, dvh + safe-area, fonte
  do editor ≥16px (anti-zoom iOS)
- Abas: várias notas abertas com estado sujo por aba, ✕ com confirmação,
  clique do meio, Alt+W / Alt+←/→ / Alt+1..9; abas e rascunhos sobrevivem à
  sessão via localStorage
- Categorias: modelo vira árvore (categories.json) com notas em múltiplas
  categorias; explorador no sidebar com expandir/renomear/mover/excluir
  (sem ciclos, GC de vínculos) e seletor multi-marca no editor; filtro por
  pasta recursivo por padrão; migração automática do campo único legado
- API: POST/PUT/DELETE /api/categories; notas aceitam categories: [ids]
- Testes: store (árvore, ciclo, migração, GC) e api (CRUD de categorias)
2026-11-04 11:54:15 -03:00

376 lines
12 KiB
Go

package store
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := New(t.TempDir())
if err != nil {
t.Fatalf("New: %v", err)
}
return s
}
func mustCat(t *testing.T, s *Store, name, parent string) string {
t.Helper()
c, err := s.CreateCategory(name, parent)
if err != nil {
t.Fatalf("CreateCategory(%q): %v", name, err)
}
return c.ID
}
func TestCreateExtractsTagsAndIgnoresHeadings(t *testing.T) {
s := newTestStore(t)
content := "# Título do post\n\nUso #Linux e #devops.\n#linux repetida, #Hardware\n"
n, err := s.Create("Boas vindas", FormatMD, nil, content, "lg", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if len(n.Tags) != 3 {
t.Fatalf("esperava 3 tags (devops, hardware, linux), obtive %v", n.Tags)
}
for _, tag := range []string{"devops", "hardware", "linux"} {
if !contains(n.Tags, tag) {
t.Errorf("tag %q ausente em %v", tag, n.Tags)
}
}
if n.Hash == "" || n.Bytes != len(content) {
t.Errorf("hash/bytes errados: %q %d", n.Hash, n.Bytes)
}
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
s := newTestStore(t)
dev := mustCat(t, s, "dev", "")
n, err := s.Create("nota", FormatTxt, []string{dev}, "v1", "lg", "")
if err != nil {
t.Fatal(err)
}
h1, err := s.History(n.ID)
if err != nil || len(h1) != 1 {
t.Fatalf("história inicial: %v %v", len(h1), err)
}
// Só metadados: nenhuma revisão nova (troca de categoria/título).
house := mustCat(t, s, "casa", "")
if _, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev, house}, "v1", "lg", ""); err != nil {
t.Fatal(err)
}
if h, _ := s.History(n.ID); len(h) != 1 {
t.Fatalf("conteúdo inalterado não deveria criar revisão; história: %+v", h)
}
got, _ := s.Get(n.ID)
if len(got.Categories) != 2 {
t.Errorf("múltiplas categorias não persistidas: %+v", got.Categories)
}
// Conteúdo novo: segunda revisão com parent correto.
n2, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev}, "v2", "lg", "ajuste final")
if err != nil {
t.Fatal(err)
}
h2, err := s.History(n.ID)
if err != nil || len(h2) != 2 {
t.Fatalf("esperava 2 revisões, obtive %d (%v)", len(h2), err)
}
if h2[1].Parent != h1[0].Hash || h2[1].Hash != n2.Hash {
t.Errorf("corrente de revisões quebrada: %+v", h2)
}
if h2[1].Message != "ajuste final" {
t.Errorf("mensagem: %q", h2[1].Message)
}
content, _, err := s.Content(n.ID)
if err != nil || content != "v2" {
t.Fatalf("content=%q err=%v", content, err)
}
}
func TestRestoreAppendsRevisionWithOldContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("r", FormatMD, nil, "primeiro", "", "")
if _, err := s.Update(n.ID, "r", FormatMD, nil, "segundo", "", ""); err != nil {
t.Fatal(err)
}
first, err := s.History(n.ID)
if err != nil {
t.Fatal(err)
}
restored, err := s.Restore(n.ID, first[0].Hash, "lg")
if err != nil {
t.Fatalf("Restore: %v", err)
}
if restored.Hash != first[0].Hash {
t.Errorf("nota restaurada deveria apontar para o blob antigo: %+v", restored)
}
content, _, err := s.Content(n.ID)
if err != nil || content != "primeiro" {
t.Fatalf("pós-resturo content=%q err=%v", content, err)
}
h, _ := s.History(n.ID)
if len(h) != 3 || !strings.HasPrefix(h[2].Message, "Restauração da versão") {
t.Fatalf("história pós-resturo: %+v", h)
}
if _, err := s.Restore(n.ID, first[0].Hash, "lg"); !errors.Is(err, ErrUnchanged) {
t.Errorf("esperava ErrUnchanged, obtive %v", err)
}
if _, err := s.Restore(n.ID, strings.Repeat("f", 64), "lg"); !errors.Is(err, ErrRevision) {
t.Errorf("esperava ErrRevision, obtive %v", err)
}
}
func TestCategoryTreeOperations(t *testing.T) {
s := newTestStore(t)
devops := mustCat(t, s, "devops", "")
k8s := mustCat(t, s, "kubernetes", devops)
pessoal := mustCat(t, s, "pessoal", "")
// nomes duplicados entre irmãos são recusados (case-insensitive)
if _, err := s.CreateCategory("DevOps", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("duplicata de raiz deveria falhar: %v", err)
}
// mas o mesmo nome em pais diferentes é ok
mustCat(t, s, "notes", k8s)
n1, _ := s.Create("deploy", FormatMD, []string{k8s}, "conteúdo", "", "")
n2, _ := s.Create("compras", FormatTxt, []string{pessoal}, "oi", "", "")
n3, _ := s.Create("shell", FormatTxt, []string{devops, pessoal}, "duas categorias", "", "")
// filtro recursivo pela raiz devops: só n1 (k8s) e n3 (devops direto)
rec, err := s.List(Filter{Category: devops, Recursive: true})
if err != nil || len(rec) != 2 {
t.Fatalf("filtro recursivo: %d notas %v", len(rec), err)
}
// não-recursivo: só n3
nonrec, _ := s.List(Filter{Category: devops})
if len(nonrec) != 1 || nonrec[0].ID != n3.ID {
t.Errorf("filtro não-recursivo: %+v", nonrec)
}
// múltiplas categorias: n3 aparece nos dois filtros
if r, _ := s.List(Filter{Category: pessoal}); len(r) != 2 {
t.Errorf("filtro pessoal (n2+n3): %+v", r)
}
// none
if r, _ := s.List(Filter{Category: "none"}); len(r) != 0 {
t.Errorf("todas têm categoria: %+v", r)
}
n4, _ := s.Create("livre", FormatTxt, nil, "x", "", "")
if r, _ := s.List(Filter{Category: "none"}); len(r) != 1 || r[0].ID != n4.ID {
t.Errorf("filtro none: %+v", r)
}
// mover k8s para pessoal (sem ciclo)
if _, err := s.UpdateCategory(k8s, "", ptr(pessoal)); err != nil {
t.Fatalf("move: %v", err)
}
if r, _ := s.List(Filter{Category: devops, Recursive: true}); len(r) != 1 {
t.Errorf("após mover k8s, devops recursivo deveria ter só n3: %+v", r)
}
// ciclo: mover devops para dentro de si mesmo
if _, err := s.UpdateCategory(devops, "", ptr(devops)); !errors.Is(err, ErrInvalid) {
t.Errorf("ciclo direto deveria falhar: %v", err)
}
// ciclo via descendente: mover pessoal para k8s (k8s é filho de pessoal)
if _, err := s.UpdateCategory(pessoal, "", ptr(k8s)); !errors.Is(err, ErrInvalid) {
t.Errorf("ciclo via descendente deveria falhar: %v", err)
}
// renomear
upd, err := s.UpdateCategory(devops, "infra", nil)
if err != nil || upd.Name != "infra" {
t.Fatalf("rename: %+v %v", upd, err)
}
// excluir categoria-pai remove subtree e limpa vínculos das notas
if err := s.DeleteCategory(pessoal); err != nil {
t.Fatalf("delete: %v", err)
}
got2, _ := s.Get(n2.ID)
if len(got2.Categories) != 0 {
t.Errorf("n2 deveria perder a categoria excluída: %+v", got2.Categories)
}
got3, _ := s.Get(n3.ID)
if len(got3.Categories) != 1 || got3.Categories[0] != upd.ID {
t.Errorf("n3 deveria manter só infra: %+v", got3.Categories)
}
cats, _ := s.Categories()
for _, c := range cats {
if c.ID == pessoal || c.ID == k8s {
t.Errorf("subtree não removida: %+v", cats)
}
}
// categoria inexistente em nota → ErrInvalid
if _, err := s.Update(n1.ID, "deploy", FormatMD, []string{"naoexiste1"}, "x", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("categoria fantasma: %v", err)
}
// busca por nome de categoria: só n3 ainda é "infra" (n1 ficou órfã com a exclusão)
r, _ := s.List(Filter{Query: "infra"})
if len(r) != 1 || r[0].ID != n3.ID {
t.Errorf("busca por nome de categoria: %+v", ids(r))
}
}
func ptr(s string) *string { return &s }
func TestLegacyCategoryMigration(t *testing.T) {
dir := t.TempDir()
noteDir := filepath.Join(dir, "notes", "abcd12345678")
if err := os.MkdirAll(noteDir, 0o750); err != nil {
t.Fatal(err)
}
legacy := map[string]any{
"id": "abcd12345678", "title": "velha", "format": "txt",
"category": "hardware", "tags": []string{}, "hash": strings.Repeat("0", 64),
"bytes": 0, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z",
}
data, _ := json.Marshal(legacy)
if err := os.WriteFile(filepath.Join(noteDir, "meta.json"), data, 0o640); err != nil {
t.Fatal(err)
}
s2, err := New(dir)
if err != nil {
t.Fatalf("New pós-legado: %v", err)
}
n, err := s2.Get("abcd12345678")
if err != nil {
t.Fatal(err)
}
if len(n.Categories) != 1 {
t.Fatalf("migração não criou categoria: %+v", n)
}
cats, _ := s2.Categories()
if len(cats) != 1 || cats[0].Name != "hardware" || cats[0].Parent != "" || cats[0].NoteCount != 1 {
t.Errorf("categoria legada errada: %+v", cats)
}
if r, _ := s2.List(Filter{Category: cats[0].ID}); len(r) != 1 {
t.Errorf("nota não filtrável pela categoria migrada")
}
}
func TestListFiltersAndSorts(t *testing.T) {
s := newTestStore(t)
dev := mustCat(t, s, "dev", "")
a, _ := s.Create("Alpha linux", FormatMD, []string{dev}, "conteúdo #linux", "", "")
s.Update(a.ID, "Alpha linux", FormatMD, []string{dev}, "conteúdo #linux editado", "", "")
b, _ := s.Create("Beta", FormatTxt, nil, "compras", "", "")
c, _ := s.Create("Gamma docker", FormatHTML, []string{dev}, "<p>#devops #linux</p>", "", "")
all, err := s.List(Filter{})
if err != nil || len(all) != 3 {
t.Fatalf("list tudo: %d notas, %v", len(all), err)
}
if all[0].ID != c.ID { // mais recente primeiro
t.Errorf("ordenação por UpdatedAt errada: %v", ids(all))
}
byCat, _ := s.List(Filter{Category: dev})
if len(byCat) != 2 {
t.Errorf("categoria dev: %+v", ids(byCat))
}
byTag, _ := s.List(Filter{Tag: "#linux"})
if len(byTag) != 2 { // alpha e gamma contêm #linux
t.Errorf("tag linux: %+v", ids(byTag))
}
byQ, _ := s.List(Filter{Query: "compras"})
if len(byQ) != 1 || byQ[0].ID != b.ID {
t.Errorf("busca em conteúdo 'compras': %+v", ids(byQ))
}
tags, _ := s.Tags()
if len(tags) != 2 || tags[0].Tag != "linux" || tags[0].Count != 2 {
t.Errorf("agregação de tags: %+v", tags)
}
}
func ids(ns []*Note) []string {
var out []string
for _, n := range ns {
out = append(out, n.ID)
}
return out
}
func TestDeleteGCsOrphanBlobs(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("gc", FormatTxt, nil, "conteúdo único para o blob", "", "")
blob := filepath.Join(s.dir, "blobs", n.Hash[:2], n.Hash)
if _, err := os.Stat(blob); err != nil {
t.Fatalf("blob deveria existir: %v", err)
}
if err := s.Delete(n.ID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(blob); !errors.Is(err, os.ErrNotExist) {
t.Errorf("blob órfão não coletado")
}
if _, err := s.Get(n.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Get pós-delete: %v", err)
}
if err := s.Delete(n.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Delete duplo deveria dar ErrNotFound, obtive %v", err)
}
}
func TestSharedBlobSurvivesDeleteOfOneNote(t *testing.T) {
s := newTestStore(t)
a, _ := s.Create("a", FormatTxt, nil, "mesmo", "", "")
b, _ := s.Create("b", FormatTxt, nil, "mesmo", "", "") // mesmo hash
if a.Hash != b.Hash {
t.Fatal("blobs deveriam ser deduplicados por conteúdo")
}
if err := s.Delete(a.ID); err != nil {
t.Fatal(err)
}
content, _, err := s.Content(b.ID)
if err != nil || content != "mesmo" {
t.Fatalf("nota sobrevivente perdeu conteúdo: %q %v", content, err)
}
}
func TestInvalidFormatAndOversizeContent(t *testing.T) {
s := newTestStore(t)
if _, err := s.Create("x", "rtf", nil, "", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("formato inválido: %v", err)
}
if _, err := s.Update("naoexiste1", "x", FormatTxt, nil, "a", "", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("update em nota ausente: %v", err)
}
if _, err := s.Create("x", FormatTxt, nil, strings.Repeat("a", MaxContentBytes+1), "", ""); !errors.Is(err, ErrTooLarge) {
t.Errorf("conteúdo gigante: %v", err)
}
}
func TestFilenameAndRevisionContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("minha/nota: ruim", FormatMD, nil, "oi #t", "", "")
if got := n.Filename(); got != "minhanota ruim.md" {
t.Errorf("filename: %q", got)
}
rc, err := s.RevisionWithContent(n.ID, n.Hash)
if err != nil || rc.Content != "oi #t" {
t.Fatalf("RevisionWithContent: %+v %v", rc, err)
}
if _, err := s.RevisionWithContent(n.ID, strings.Repeat("0", 64)); !errors.Is(err, ErrRevision) {
t.Errorf("hash inexistente: %v", err)
}
before, after, _, err := s.DiffBetween(n.ID, n.Hash)
if err != nil || before != "" || after != "oi #t" {
t.Fatalf("DiffBetween primeira revisão: %q %q %v", before, after, err)
}
}