go-doc/backend/tests/docgen_import_test.go

221 lines
7.6 KiB
Go
Raw Normal View History

2026-10-07 15:03:50 +02:00
// This file holds the end-to-end suite for the documentation importer: analyse a
// real project tree on disk, render a page, merge it through the storage engine,
// and read it back over HTTP. No mocks anywhere -- if the pipeline breaks, these
// tests break.
package tests
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"go-doc/docgen"
)
// getJSON fetches a resource as JSON, which is how the frontend reads pages.
// The API negotiates with ?format=json rather than the Accept header.
func (h *harness) getJSON(path string) map[string]any {
h.t.Helper()
if !strings.Contains(path, "?") {
path += "?format=json"
}
payload := h.expectStatus(http.MethodGet, path, "", "", http.StatusOK)
return decodeJSON[map[string]any](h.t, payload)
}
func TestIntegrationDocgenImport(t *testing.T) {
t.Parallel()
h := newHarness(t)
workspace := t.TempDir()
writeFixtureProject(t, filepath.Join(workspace, "go-fixture"))
project, err := docgen.Analyze("go-fixture", filepath.Join(workspace, "go-fixture"))
if err != nil {
t.Fatalf("integration: analyse fixture: %v", err)
}
page, err := docgen.Render(project)
if err != nil {
t.Fatalf("integration: render fixture: %v", err)
}
slug, err := h.engine.SaveDoc(project.Name, page)
if err != nil {
t.Fatalf("integration: import page: %v", err)
}
if slug != "go-fixture" {
t.Fatalf("integration: expected slug go-fixture, got %q", slug)
}
// The page lands on disk exactly once, wrapped in generated markers that
// record which project it came from.
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "go-fixture.md")))
if !strings.HasPrefix(onDisk, "<!-- go-doc:generated:begin") {
t.Errorf("imported page is missing its opening marker:\n%s", firstLineOf(onDisk))
}
if !strings.Contains(onDisk, "source=go-fixture") {
t.Errorf("imported page does not record its source project:\n%s", firstLineOf(onDisk))
}
if strings.Count(onDisk, "go-doc:generated:end") != 1 {
t.Errorf("imported page has %d closing markers, want 1",
strings.Count(onDisk, "go-doc:generated:end"))
}
// The API serves it under the title the index shows.
doc := h.getJSON("/api/docs/go-fixture")
if doc["title"] != "go-fixture" {
t.Errorf("title = %v, want go-fixture", doc["title"])
}
content, _ := doc["content"].(string)
if content != onDisk {
t.Error("API content differs from the file on disk")
}
// Every audience tab the reader keys off is present, in order.
users := strings.Index(content, "## For Users")
operators := strings.Index(content, "## For Operators")
developers := strings.Index(content, "## For Developers")
if users < 0 || operators < 0 || developers < 0 {
t.Fatalf("audience sections missing: %d %d %d", users, operators, developers)
}
if !(users < operators && operators < developers) {
t.Errorf("audience sections are out of order: %d %d %d", users, operators, developers)
}
// Facts extracted from the source reached the page.
for _, want := range []string{"| `GET` | `/healthz` | `health` |", "`DEMO_HTTP_PORT`", "`make build`"} {
if !strings.Contains(content, want) {
t.Errorf("imported page is missing extracted fact %q", want)
}
}
// It appears in the index.
index := h.getJSON("/api/docs")
docs, _ := index["docs"].([]any)
if len(docs) != 1 {
t.Fatalf("expected 1 indexed document, got %d", len(docs))
}
entry, _ := docs[0].(map[string]any)
if entry["slug"] != "go-fixture" {
t.Errorf("index slug = %v, want go-fixture", entry["slug"])
}
// A comment attaches to a generated page like any other document.
h.expectStatus(http.MethodPost, "/api/docs/go-fixture/comments",
"application/json",
`{"role":"operator","author":"sre","content":"The port table needs the compose port too."}`,
http.StatusCreated)
stats := h.getJSON("/api/docs/go-fixture/stats")
if stats["total"] != float64(1) {
t.Errorf("comment count = %v, want 1", stats["total"])
}
// Re-importing identical content changes nothing, and prose a human added
// outside the markers survives the sync.
pagePath := filepath.Join(h.docsDir, "go-fixture.md")
withFooter := onDisk + "\n## Notes from the team\n\nCheck the runbook before deploying.\n"
if err := os.WriteFile(pagePath, []byte(withFooter), 0o644); err != nil {
t.Fatalf("integration: write page: %v", err)
}
merged, status, err := docgen.Merge(withFooter, page, false)
if err != nil {
t.Fatalf("integration: re-merge: %v", err)
}
if status != docgen.StatusUnchanged {
t.Errorf("re-importing an unchanged project reported %q, want unchanged", status)
}
if _, err := h.engine.SaveDoc(project.Name, merged); err != nil {
t.Fatalf("integration: re-import: %v", err)
}
if reread := string(requireFile(t, pagePath)); !strings.Contains(reread, "Check the runbook before deploying.") {
t.Error("re-import dropped hand-written prose")
}
}
// TestIntegrationDocgenProtectsHandWrittenPages proves the importer never
// overwrites a document that it did not generate.
func TestIntegrationDocgenProtectsHandWrittenPages(t *testing.T) {
t.Parallel()
h := newHarness(t)
const handWritten = "# go-doc\n\nWritten by a person, with no generated markers.\n"
if _, err := h.engine.SaveDoc("go-doc", handWritten); err != nil {
t.Fatalf("integration: seed hand-written page: %v", err)
}
generated, err := docgen.Render(&docgen.Project{Name: "go-doc", Summary: "Machine written."})
if err != nil {
t.Fatalf("integration: render: %v", err)
}
pagePath := filepath.Join(h.docsDir, "go-doc.md")
existing := string(requireFile(t, pagePath))
merged, status, err := docgen.Merge(existing, generated, false)
if err != nil {
t.Fatalf("integration: merge: %v", err)
}
if status != docgen.StatusSkipped {
t.Errorf("merge status = %q, want skipped for an unmarked page", status)
}
if merged != existing {
t.Error("a hand-written page was modified by the importer")
}
if _, err := h.engine.SaveDoc("go-doc", merged); err != nil {
t.Fatalf("integration: save: %v", err)
}
if reread := string(requireFile(t, pagePath)); reread != handWritten {
t.Errorf("hand-written page changed on disk:\n%s", reread)
}
}
// TestIntegrationDocgenMissingProjectIsReportedNotIgnored covers the case the
// importer must never paper over: a project directory that cannot be analysed.
func TestIntegrationDocgenMissingProjectIsReportedNotIgnored(t *testing.T) {
t.Parallel()
workspace := t.TempDir()
if _, err := docgen.Analyze("go-absent", filepath.Join(workspace, "go-absent")); err == nil {
t.Fatal("analysing a missing project should fail loudly")
}
}
// writeFixtureProject lays out a small project exercising the extractors the
// importer depends on: routes, flags, environment, a module, a Makefile and a
// README.
func writeFixtureProject(t *testing.T, root string) {
t.Helper()
files := map[string]string{
"README.md": "# Demo Service\n\nDemo answers questions and exposes a JSON API.\n",
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire github.com/go-chi/chi/v5 v5.2.3\n",
"Makefile": "build:\n\tgo build ./...\n",
".env.example": "DEMO_HTTP_PORT=8080\n",
"internal/server/routes.go": "package server\n\n" +
"import \"net/http\"\n\n" +
"func Routes(mux *http.ServeMux) {\n" +
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
"\tmux.HandleFunc(\"/api/items\", listItems)\n}\n",
}
for name, content := range files {
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("integration: mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("integration: write %s: %v", path, err)
}
}
}
func firstLineOf(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}