From 9a7f19270c50def47be5dc1a9b30d8655d7098c9 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 14 Jul 2026 18:13:22 +0000 Subject: feat: add authenticated /docs viewer for superpowers specs/plans Renders docs/superpowers/{specs,plans}/*.md to HTML via goldmark, served from doot's own web server behind the existing session auth instead of as raw files. DOCS_DIR is configurable (defaults to docs/superpowers relative to the working directory) so the deployed server can point straight at the working repo and stay live-synced with no separate copy/deploy step for new docs. --- internal/handlers/docs_test.go | 154 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 internal/handlers/docs_test.go (limited to 'internal/handlers/docs_test.go') diff --git a/internal/handlers/docs_test.go b/internal/handlers/docs_test.go new file mode 100644 index 0000000..9d684d8 --- /dev/null +++ b/internal/handlers/docs_test.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "context" + "html/template" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +func setupDocsTestHandler(t *testing.T) *Handler { + t.Helper() + h, cleanup := setupTestHandler(t) + t.Cleanup(cleanup) + + docsDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(docsDir, "specs"), 0755); err != nil { + t.Fatalf("failed to create specs dir: %v", err) + } + if err := os.MkdirAll(filepath.Join(docsDir, "plans"), 0755); err != nil { + t.Fatalf("failed to create plans dir: %v", err) + } + if err := os.WriteFile(filepath.Join(docsDir, "specs", "2026-07-14-example-design.md"), []byte("# Example\n\nSome body text."), 0644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + + h.config.DocsDir = docsDir + return h +} + +func TestHandleDocsIndex_ListsWhitelistedCategories(t *testing.T) { + h := setupDocsTestHandler(t) + + req := httptest.NewRequest("GET", "/docs", nil) + w := httptest.NewRecorder() + h.HandleDocsIndex(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + mock := h.renderer.(*MockRenderer) + if len(mock.Calls) != 1 { + t.Fatalf("expected 1 render call, got %d", len(mock.Calls)) + } + if mock.Calls[0].Name != "docs-index.html" { + t.Errorf("template name = %q, want %q", mock.Calls[0].Name, "docs-index.html") + } + + data, ok := mock.Calls[0].Data.(struct{ Categories []docsCategoryListing }) + if !ok { + t.Fatalf("unexpected data type: %T", mock.Calls[0].Data) + } + if len(data.Categories) != 2 { + t.Fatalf("expected 2 categories (specs, plans), got %d", len(data.Categories)) + } + specs := data.Categories[0] + if specs.Name != "specs" { + t.Fatalf("expected first category 'specs', got %q", specs.Name) + } + if len(specs.Entries) != 1 || specs.Entries[0].Name != "2026-07-14-example-design.md" { + t.Fatalf("expected 1 spec entry, got %+v", specs.Entries) + } + if specs.Entries[0].URL != "/docs/specs/2026-07-14-example-design.md" { + t.Errorf("URL = %q, want %q", specs.Entries[0].URL, "/docs/specs/2026-07-14-example-design.md") + } + + plans := data.Categories[1] + if plans.Name != "plans" || len(plans.Entries) != 0 { + t.Fatalf("expected empty plans category, got %+v", plans) + } +} + +func routeWithParams(method, target, category, filename string) *http.Request { + req := httptest.NewRequest(method, target, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("category", category) + rctx.URLParams.Add("filename", filename) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func TestHandleDocsView_RendersMarkdownFile(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/specs/2026-07-14-example-design.md", "specs", "2026-07-14-example-design.md") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + mock := h.renderer.(*MockRenderer) + if len(mock.Calls) != 1 || mock.Calls[0].Name != "docs-view.html" { + t.Fatalf("expected 1 call to docs-view.html, got %+v", mock.Calls) + } + data, ok := mock.Calls[0].Data.(struct { + Title string + Body template.HTML + }) + if !ok { + t.Fatalf("unexpected data type: %T", mock.Calls[0].Data) + } + if data.Title != "2026-07-14-example-design.md" { + t.Errorf("Title = %q, want %q", data.Title, "2026-07-14-example-design.md") + } + if !strings.Contains(string(data.Body), "

Example

") { + t.Errorf("Body does not contain expected rendered heading: %s", data.Body) + } + if !strings.Contains(string(data.Body), "Some body text.") { + t.Errorf("Body does not contain expected paragraph text: %s", data.Body) + } +} + +func TestHandleDocsView_RejectsUnknownCategory(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/other/2026-07-14-example-design.md", "other", "2026-07-14-example-design.md") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for unknown category, got %d", w.Code) + } +} + +func TestHandleDocsView_RejectsNonMarkdownFilename(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/specs/..", "specs", "..") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for a non-.md filename (e.g. '..'), got %d", w.Code) + } +} + +func TestHandleDocsView_RejectsMissingFile(t *testing.T) { + h := setupDocsTestHandler(t) + + req := routeWithParams("GET", "/docs/specs/does-not-exist.md", "specs", "does-not-exist.md") + w := httptest.NewRecorder() + h.HandleDocsView(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for a missing file, got %d", w.Code) + } +} -- cgit v1.2.3