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 := `

` + name + `

` start := strings.Index(body, header) if start == -1 { return "" } rest := body[start+len(header):] next := strings.Index(rest, `

`) 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, `

dev

`) { t.Fatalf("expected dev group header") } if !strings.Contains(body, `

notes

`) { 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, "

Beta

") { 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, "

Beta

") { 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, `

root

`) { 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]) } }