Files
notas-online/internal/store/store_test.go
T
lgcosta 9bc5de4c56 feat: aplicação inicial — gerenciador de notas web estilo Mousepad
- Backend Go: API REST, auth por token (Bearer + cookie HttpOnly), storage em disco
  com revisões content-addressed (estilo Git: hash, autor, data, diff, restauração)
- Editor txt/md/html com preview, tags #tag, categorias, painel de histórico
- Preferências: números de linha, marcador de coluna (padrão 80), quebra de linha,
  fonte/tamanho, 4 temas e colorização de sintaxe (overlay no textarea)
- Frontend vanilla embutido via go:embed; Dockerfile multi-stage + docker-compose
2026-11-04 09:41:46 -03:00

242 lines
7.4 KiB
Go

package store
import (
"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 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, "infra", 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)
}
}
// cabeçalho "# Título" não deve virar tag ("Título" nem entraria, e "título" tem acento)
for _, tag := range n.Tags {
if strings.Contains(tag, "t") && strings.HasPrefix(tag, "tí") {
t.Errorf("cabeçalho vazou como tag: %v", 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)
n, err := s.Create("nota", FormatTxt, "", "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.
if _, err := s.Update(n.ID, "renomeada", FormatTxt, "pasta", "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)
}
// Conteúdo novo: segunda revisão com parent correto.
n2, err := s.Update(n.ID, "renomeada", FormatTxt, "pasta", "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, "", "primeiro", "", "")
if _, err := s.Update(n.ID, "r", FormatMD, "", "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)
}
// Restaurar a versão já atual devolve ErrUnchanged.
if _, err := s.Restore(n.ID, first[0].Hash, "lg"); !errors.Is(err, ErrUnchanged) {
t.Errorf("esperava ErrUnchanged, obtive %v", err)
}
// Hash desconhecido devolve ErrRevision.
if _, err := s.Restore(n.ID, strings.Repeat("f", 64), "lg"); !errors.Is(err, ErrRevision) {
t.Errorf("esperava ErrRevision, obtive %v", err)
}
}
func TestListFiltersAndSorts(t *testing.T) {
s := newTestStore(t)
a, _ := s.Create("Alpha linux", FormatMD, "dev", "conteúdo #linux", "", "")
s.Update(a.ID, "Alpha linux", FormatMD, "dev", "conteúdo #linux editado", "", "")
b, _ := s.Create("Beta", FormatTxt, "casa", "compras", "", "")
c, _ := s.Create("Gamma docker", FormatHTML, "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 (gamma criada por último)
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 (md) e gamma (html) 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)
}
cats, _ := s.Categories()
if len(cats) != 2 || cats[0].Category != "casa" || cats[1].Category != "dev" {
t.Errorf("agregação de categorias: %+v", cats)
}
}
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, "", "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, "", "mesmo", "", "")
b, _ := s.Create("b", FormatTxt, "", "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", "", "", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("formato inválido: %v", err)
}
if _, err := s.Update("naoexiste1", "x", FormatTxt, "", "a", "", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("update em nota ausente: %v", err)
}
if _, err := s.Create("x", FormatTxt, "", 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, "", "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)
}
}