commit 533ac4e58256e6520a86af964fcf4c2f9a98d4ba Author: adminoo <git@kadath.corp> Date: Mon Feb 23 18:52:59 2026 +0100 feat: freebsd release tarball generator commit874fb63fd0Author: adminoo <git@kadath.corp> Date: Mon Feb 23 14:05:24 2026 +0100 feat: bump changelog commit46ab7e2911Author: adminoo <git@kadath.corp> Date: Mon Feb 23 13:58:14 2026 +0100 feat: margin and page breaks commit44751a808aAuthor: adminoo <git@kadath.corp> Date: Mon Feb 23 13:57:56 2026 +0100 feat: picture are worth thousand words commita5683428e0Author: adminoo <git@kadath.corp> Date: Mon Feb 23 13:39:00 2026 +0100 feat: navigate individual sections commit0d9b7c4e7bAuthor: adminoo <git@kadath.corp> Date: Mon Feb 23 13:38:19 2026 +0100 feat: make use of vendoring
582 lines
14 KiB
Go
582 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"donniemarko/internal/note"
|
|
"donniemarko/internal/render"
|
|
"donniemarko/internal/service"
|
|
"donniemarko/internal/storage"
|
|
)
|
|
|
|
type testEnv struct {
|
|
handler *Handler
|
|
storage *storage.NoteStorage
|
|
}
|
|
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
ns := storage.NewNoteStorage()
|
|
|
|
notes := []*note.Note{
|
|
{
|
|
ID: "a1",
|
|
Title: "Alpha",
|
|
Content: "# Alpha\ncontent",
|
|
Path: "notes/alpha.md",
|
|
UpdatedAt: time.Date(2025, 12, 31, 10, 0, 0, 0, time.UTC),
|
|
},
|
|
{
|
|
ID: "b2",
|
|
Title: "Beta",
|
|
Content: "# Beta\nRust tips",
|
|
Path: "dev/beta.md",
|
|
UpdatedAt: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC),
|
|
},
|
|
{
|
|
ID: "z9",
|
|
Title: "Zulu",
|
|
Content: "# Zulu\nRust book",
|
|
Path: "notes/zulu.md",
|
|
UpdatedAt: time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC),
|
|
},
|
|
}
|
|
|
|
for _, n := range notes {
|
|
if err := ns.Create(n); err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
}
|
|
|
|
svc := service.NewNoteService()
|
|
svc.SetStorage(ns)
|
|
|
|
tm := render.NewTemplateManager("templates")
|
|
handler := NewHandler(svc, tm)
|
|
|
|
return &testEnv{
|
|
handler: handler,
|
|
storage: ns,
|
|
}
|
|
}
|
|
|
|
func assertOrder(t *testing.T, body string, want []string) {
|
|
t.Helper()
|
|
|
|
prev := -1
|
|
for _, title := range want {
|
|
idx := strings.Index(body, title)
|
|
if idx == -1 {
|
|
t.Fatalf("expected to find %q in response body", title)
|
|
}
|
|
if idx <= prev {
|
|
t.Fatalf("expected %q to appear after previous title", title)
|
|
}
|
|
prev = idx
|
|
}
|
|
}
|
|
|
|
func noteMarker(id string) string {
|
|
return `data-hash="` + id + `"`
|
|
}
|
|
|
|
func groupSection(body, name string) string {
|
|
header := `<h3 class="note-group">` + name + `</h3>`
|
|
start := strings.Index(body, header)
|
|
if start == -1 {
|
|
return ""
|
|
}
|
|
rest := body[start+len(header):]
|
|
next := strings.Index(rest, `<h3 class="note-group">`)
|
|
if next == -1 {
|
|
return rest
|
|
}
|
|
return rest[:next]
|
|
}
|
|
|
|
func TestHandlerRoot_DefaultSortRecent(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
|
t.Fatalf("expected content-type text/html; charset=utf-8, got %q", ct)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `<h3 class="note-group">dev</h3>`) {
|
|
t.Fatalf("expected dev group header")
|
|
}
|
|
if !strings.Contains(body, `<h3 class="note-group">notes</h3>`) {
|
|
t.Fatalf("expected notes group header")
|
|
}
|
|
|
|
devSection := groupSection(body, "dev")
|
|
if devSection == "" {
|
|
t.Fatalf("expected dev section")
|
|
}
|
|
assertOrder(t, devSection, []string{noteMarker("b2")})
|
|
|
|
notesSection := groupSection(body, "notes")
|
|
if notesSection == "" {
|
|
t.Fatalf("expected notes section")
|
|
}
|
|
assertOrder(t, notesSection, []string{noteMarker("z9"), noteMarker("a1")})
|
|
}
|
|
|
|
func TestHandlerRoot_SortAlpha(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/?sort=alpha", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
devSection := groupSection(body, "dev")
|
|
if devSection == "" {
|
|
t.Fatalf("expected dev section")
|
|
}
|
|
assertOrder(t, devSection, []string{noteMarker("b2")})
|
|
|
|
notesSection := groupSection(body, "notes")
|
|
if notesSection == "" {
|
|
t.Fatalf("expected notes section")
|
|
}
|
|
assertOrder(t, notesSection, []string{noteMarker("a1"), noteMarker("z9")})
|
|
}
|
|
|
|
func TestHandlerRoot_Search(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/?search=rust&sort=alpha", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Matching results for query 'rust'") {
|
|
t.Fatalf("expected search header to be rendered")
|
|
}
|
|
|
|
if strings.Contains(body, `data-hash="a1"`) {
|
|
t.Fatalf("expected non-matching note to be excluded")
|
|
}
|
|
|
|
assertOrder(t, body, []string{noteMarker("b2"), noteMarker("z9")})
|
|
}
|
|
|
|
func TestHandlerNotes_Success(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/notes/b2", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "<h1>Beta</h1>") {
|
|
t.Fatalf("expected rendered markdown for note content")
|
|
}
|
|
if !strings.Contains(body, `class="active-note"`) {
|
|
t.Fatalf("expected active note class in list")
|
|
}
|
|
if !strings.Contains(body, `data-hash="b2"`) {
|
|
t.Fatalf("expected active note hash in list")
|
|
}
|
|
}
|
|
|
|
func TestHandlerNotes_NotFound(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/notes/missing", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected status 404, got %d", rec.Code)
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), "Note not found") {
|
|
t.Fatalf("expected not found message")
|
|
}
|
|
}
|
|
|
|
func TestHandler_NotFoundPath(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected status 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestExtractHash(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
want string
|
|
}{
|
|
{
|
|
name: "valid note path",
|
|
path: "/notes/abcd",
|
|
want: "abcd",
|
|
},
|
|
{
|
|
name: "missing hash",
|
|
path: "/notes/",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "wrong prefix",
|
|
path: "/other/abcd",
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := extractHash(tc.path); got != tc.want {
|
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerTags_Add(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
form := url.Values{}
|
|
form.Set("tag", " Go ")
|
|
req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("expected status 303, got %d", rec.Code)
|
|
}
|
|
|
|
n, err := env.storage.Get("b2")
|
|
if err != nil {
|
|
t.Fatalf("get note: %v", err)
|
|
}
|
|
if len(n.Tags) != 1 || n.Tags[0] != "go" {
|
|
t.Fatalf("expected normalized tag on note, got %+v", n.Tags)
|
|
}
|
|
}
|
|
|
|
func TestHandlerTags_Remove(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
if err := env.storage.AddTag("b2", "go"); err != nil {
|
|
t.Fatalf("seed tag: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags/go", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("expected status 303, got %d", rec.Code)
|
|
}
|
|
|
|
n, err := env.storage.Get("b2")
|
|
if err != nil {
|
|
t.Fatalf("get note: %v", err)
|
|
}
|
|
if len(n.Tags) != 0 {
|
|
t.Fatalf("expected tag to be removed, got %+v", n.Tags)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRoot_TagFilter(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
if err := env.storage.AddTag("a1", "go"); err != nil {
|
|
t.Fatalf("seed tag: %v", err)
|
|
}
|
|
if err := env.storage.AddTag("b2", "rust"); err != nil {
|
|
t.Fatalf("seed tag: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/?tag=go", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if strings.Contains(body, noteMarker("b2")) {
|
|
t.Fatalf("expected non-matching note to be excluded")
|
|
}
|
|
if !strings.Contains(body, noteMarker("a1")) {
|
|
t.Fatalf("expected matching note to be included")
|
|
}
|
|
}
|
|
|
|
func TestHandlerRoot_RendersActiveNote(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/?search=rust&active=b2", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "<h1>Beta</h1>") {
|
|
t.Fatalf("expected rendered active note content")
|
|
}
|
|
}
|
|
|
|
func TestHandlerRoot_RendersRootGroupHeader(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
rootNote := ¬e.Note{
|
|
ID: "r1",
|
|
Title: "Root Note",
|
|
Content: "# Root\ncontent",
|
|
Path: "root.md",
|
|
UpdatedAt: time.Date(2026, 1, 3, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
if err := env.storage.Create(rootNote); err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `<h3 class="note-group">root</h3>`) {
|
|
t.Fatalf("expected root group header")
|
|
}
|
|
if !strings.Contains(body, noteMarker("r1")) {
|
|
t.Fatalf("expected root note to be rendered")
|
|
}
|
|
}
|
|
|
|
func TestRootFolderName(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
want string
|
|
}{
|
|
{name: "empty", path: "", want: "root"},
|
|
{name: "dot", path: ".", want: "root"},
|
|
{name: "file at root", path: "bookmarks.md", want: "root"},
|
|
{name: "single", path: "notes/file.md", want: "notes"},
|
|
{name: "nested", path: "dev/ideas/note.md", want: "dev"},
|
|
{name: "absolute", path: "/writings/notes/a.md", want: "writings"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := rootFolderName(tc.path); got != tc.want {
|
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGroupNotesByFolder(t *testing.T) {
|
|
notes := []*note.Note{
|
|
{ID: "a1", Title: "Alpha", Path: "notes/a.md"},
|
|
{ID: "b2", Title: "Beta", Path: "dev/b.md"},
|
|
{ID: "c3", Title: "Gamma", Path: "notes/c.md"},
|
|
}
|
|
|
|
groups := groupNotesByFolder(notes)
|
|
if len(groups) != 2 {
|
|
t.Fatalf("expected 2 groups, got %d", len(groups))
|
|
}
|
|
|
|
if groups[0].Name != "dev" || len(groups[0].Notes) != 1 {
|
|
t.Fatalf("unexpected dev group: %+v", groups[0])
|
|
}
|
|
if groups[1].Name != "notes" || len(groups[1].Notes) != 2 {
|
|
t.Fatalf("unexpected notes group: %+v", groups[1])
|
|
}
|
|
}
|
|
|
|
func TestHandlerNotes_SectionLinks(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
sectionNote := ¬e.Note{
|
|
ID: "s1",
|
|
Title: "Sections",
|
|
Content: "# Sections\nIntro\n## 2026-01-01 - First\nFirst body\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
|
|
Path: "notes/sections.md",
|
|
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
if err := env.storage.Create(sectionNote); err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/notes/s1", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `href="/notes/s1/sections/2026-01-01-first"`) {
|
|
t.Fatalf("expected link for first section")
|
|
}
|
|
if !strings.Contains(body, `href="/notes/s1/sections/glossary"`) {
|
|
t.Fatalf("expected link for glossary section")
|
|
}
|
|
if !strings.Contains(body, `href="/notes/s1/sections/glossary-2"`) {
|
|
t.Fatalf("expected link for duplicate glossary section")
|
|
}
|
|
}
|
|
|
|
func TestHandlerNotes_SectionRoute(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
sectionNote := ¬e.Note{
|
|
ID: "s1",
|
|
Title: "Sections",
|
|
Content: "# Sections\nIntro\n## 2026-01-01 - First\nFirst body\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
|
|
Path: "notes/sections.md",
|
|
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
if err := env.storage.Create(sectionNote); err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/glossary", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "<h2>Glossary</h2>") {
|
|
t.Fatalf("expected glossary heading to be rendered")
|
|
}
|
|
if !strings.Contains(body, "Term A") {
|
|
t.Fatalf("expected first glossary content")
|
|
}
|
|
if strings.Contains(body, "First body") {
|
|
t.Fatalf("expected other section content to be excluded")
|
|
}
|
|
if strings.Contains(body, "Term B") {
|
|
t.Fatalf("expected second glossary content to be excluded")
|
|
}
|
|
}
|
|
|
|
func TestHandlerNotes_SectionRoute_Duplicate(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
sectionNote := ¬e.Note{
|
|
ID: "s1",
|
|
Title: "Sections",
|
|
Content: "# Sections\nIntro\n## Glossary\nTerm A\n## Glossary\nTerm B\n",
|
|
Path: "notes/sections.md",
|
|
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
if err := env.storage.Create(sectionNote); err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/glossary-2", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "<h2>Glossary</h2>") {
|
|
t.Fatalf("expected glossary heading to be rendered")
|
|
}
|
|
if !strings.Contains(body, "Term B") {
|
|
t.Fatalf("expected second glossary content")
|
|
}
|
|
if strings.Contains(body, "Term A") {
|
|
t.Fatalf("expected first glossary content to be excluded")
|
|
}
|
|
}
|
|
|
|
func TestHandlerNotes_SectionRoute_NotFound(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
sectionNote := ¬e.Note{
|
|
ID: "s1",
|
|
Title: "Sections",
|
|
Content: "# Sections\n## Alpha\nA\n",
|
|
Path: "notes/sections.md",
|
|
UpdatedAt: time.Date(2026, 1, 4, 10, 0, 0, 0, time.UTC),
|
|
}
|
|
|
|
if err := env.storage.Create(sectionNote); err != nil {
|
|
t.Fatalf("create note: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/notes/s1/sections/missing", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
env.handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected status 404, got %d", rec.Code)
|
|
}
|
|
}
|