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)
This commit is contained in:
1 parent
9bc5de4c56
commit
7e89207b70
10 files changed
+1737
-489
No files matched your search
+97
-13
@@ -61,13 +61,13 @@ func decode(t *testing.T, resp *http.Response, v any) {
|
||||
}
|
||||
|
||||
type noteView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Hash string `json:"hash"`
|
||||
Content string `json:"content"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Categories []string `json:"categories"`
|
||||
Tags []string `json:"tags"`
|
||||
Hash string `json:"hash"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func TestAuthRequiredAndLoginFlow(t *testing.T) {
|
||||
@@ -130,15 +130,26 @@ func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
|
||||
return do(t, srv, method, path, body, testToken, "")
|
||||
}
|
||||
|
||||
// Criar.
|
||||
resp := call("POST", "/api/notes",
|
||||
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
|
||||
// Criar uma categoria raiz e uma filha para vincular à nota.
|
||||
resp := call("POST", "/api/categories", `{"name":"devops","parent":""}`)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create category: %s", resp.Status)
|
||||
}
|
||||
var cat struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
decode(t, resp, &cat)
|
||||
|
||||
// Criar nota em múltiplas categorias.
|
||||
resp = call("POST", "/api/notes",
|
||||
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %s", resp.Status)
|
||||
}
|
||||
var created noteView
|
||||
decode(t, resp, &created)
|
||||
if created.ID == "" || len(created.Tags) != 2 {
|
||||
if created.ID == "" || len(created.Tags) != 2 || len(created.Categories) != 1 {
|
||||
t.Fatalf("payload criado: %+v", created)
|
||||
}
|
||||
id := created.ID
|
||||
@@ -161,7 +172,7 @@ func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
|
||||
|
||||
// Atualizar: nova revisão.
|
||||
resp = call("PUT", "/api/notes/"+id,
|
||||
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
|
||||
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
|
||||
var updated noteView
|
||||
decode(t, resp, &updated)
|
||||
if updated.Hash == created.Hash {
|
||||
@@ -231,7 +242,9 @@ func TestRenderMarkdownIsSafe(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp := do(t, srv, "POST", "/api/render",
|
||||
`{"content":"**negrito** <script>alert(1)</script>\nlinha1\nlinha2","format":"md"}`, testToken, "")
|
||||
var out struct{ HTML string `json:"html"` }
|
||||
var out struct {
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
decode(t, resp, &out)
|
||||
if !strings.Contains(out.HTML, "<strong>negrito</strong>") {
|
||||
t.Errorf("markdown não renderizado: %q", out.HTML)
|
||||
@@ -251,6 +264,77 @@ func TestRenderMarkdownIsSafe(t *testing.T) {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestCategoryCRUDViaAPI(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
call := func(method, path, body string) *http.Response {
|
||||
return do(t, srv, method, path, body, testToken, "")
|
||||
}
|
||||
|
||||
resp := call("POST", "/api/categories", `{"name":"infra","parent":""}`)
|
||||
var infra struct{ ID string }
|
||||
decode(t, resp, &infra)
|
||||
resp = call("POST", "/api/categories", `{"name":"linux","parent":"`+infra.ID+`"}`)
|
||||
var linux struct{ ID string }
|
||||
decode(t, resp, &linux)
|
||||
|
||||
// duplicata entre irmãos → 400
|
||||
resp = call("POST", "/api/categories", `{"name":"Infra","parent":""}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("duplicata: %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// nota na subcategoria
|
||||
resp = call("POST", "/api/notes", `{"title":"Kernel","format":"txt","categories":["`+linux.ID+`"],"content":"x","author":"","message":""}`)
|
||||
var note noteView
|
||||
decode(t, resp, ¬e)
|
||||
|
||||
// filtro recursivo pela raiz (padrão) → 1 nota; não-recursivo → 0
|
||||
resp = call("GET", "/api/notes?category="+infra.ID, "")
|
||||
var rec []noteView
|
||||
decode(t, resp, &rec)
|
||||
if len(rec) != 1 {
|
||||
t.Fatalf("recursivo: %+v", rec)
|
||||
}
|
||||
resp = call("GET", "/api/notes?category="+infra.ID+"&recursive=0", "")
|
||||
var nonrec []noteView
|
||||
decode(t, resp, &nonrec)
|
||||
if len(nonrec) != 0 {
|
||||
t.Fatalf("não-recursivo: %+v", nonrec)
|
||||
}
|
||||
|
||||
// ciclo: mover infra para dentro de linux → 400
|
||||
resp = call("PUT", "/api/categories/"+infra.ID, `{"name":"","parent":"`+linux.ID+`"}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("ciclo deveria dar 400, obtive %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// excluir raiz remove subtree e limpa a nota
|
||||
resp = call("DELETE", "/api/categories/"+infra.ID, "")
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %s", resp.Status)
|
||||
}
|
||||
resp = call("GET", "/api/notes/"+note.ID, "")
|
||||
decode(t, resp, ¬e)
|
||||
if len(note.Categories) != 0 {
|
||||
t.Errorf("nota deveria ficar órfã: %+v", note.Categories)
|
||||
}
|
||||
resp = call("GET", "/api/categories", "")
|
||||
var cats []map[string]any
|
||||
decode(t, resp, &cats)
|
||||
if len(cats) != 0 {
|
||||
t.Errorf("árvore deveria estar vazia: %+v", cats)
|
||||
}
|
||||
|
||||
// categoria inexistente em nota → 400
|
||||
resp = call("POST", "/api/notes", `{"title":"x","format":"txt","categories":["zzzzzzzzzzzz"],"content":"", "author":"","message":""}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("categoria fantasma: %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestInvalidPayloads(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "")
|
||||
|
||||
Reference in new issue
Block a user