- 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
242 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|