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}, "
#devops #linux
", "", "") 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) } }