package storage import ( "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" ) func newTestEngine(t *testing.T) *Engine { t.Helper() engine, err := New(t.TempDir()) if err != nil { t.Fatalf("New: %v", err) } return engine } func TestSlugify(t *testing.T) { t.Parallel() cases := []struct { input string want string }{ {"Core Service API", "core-service-api"}, {"core-service-api", "core-service-api"}, {" Spaced Out Title ", "spaced-out-title"}, {"Deploy & Rollback", "deploy-rollback"}, {"API_v2 (Internal)", "api-v2-internal"}, {"Release 2024/Q1 -- Notes", "release-2024-q1-notes"}, {"Überwachen der Dienste", "uberwachen-der-dienste"}, {"Straße", "strase"}, {"C++ & Rust: side by side", "c-rust-side-by-side"}, {"emoji 🚀 launch", "emoji-launch"}, {"multiple---hyphens", "multiple-hyphens"}, {"trailing hyphen-", "trailing-hyphen"}, {"---leading", "leading"}, {"!!!", ""}, {"", ""}, {"日本語 title", "title"}, {"snake_case_and__doubles", "snake-case-and-doubles"}, } for _, tc := range cases { if got := Slugify(tc.input); got != tc.want { t.Errorf("Slugify(%q) = %q, want %q", tc.input, got, tc.want) } } } func TestSlugifyCapsLength(t *testing.T) { t.Parallel() long := strings.Repeat("word ", 100) got := Slugify(long) if len(got) > maxSlugLen { t.Fatalf("slug length %d exceeds the %d cap", len(got), maxSlugLen) } if strings.HasSuffix(got, "-") { t.Fatalf("capped slug must not end in a hyphen: %q", got) } } func TestNormalizeSlug(t *testing.T) { t.Parallel() valid := map[string]string{ "Core Service API": "core-service-api", "core-service-api": "core-service-api", " Spaced ": "spaced", "Ünïcödé Slug": "unicode-slug", } for input, want := range valid { got, err := NormalizeSlug(input) if err != nil { t.Errorf("NormalizeSlug(%q) returned %v", input, err) continue } if got != want { t.Errorf("NormalizeSlug(%q) = %q, want %q", input, got, want) } } invalid := []string{ "", " ", "../etc/passwd", "..", "nested/path", "back\\slash", "null\x00byte", "!!!", strings.Repeat("x", 2*maxSlugLen+1), } for _, input := range invalid { if _, err := NormalizeSlug(input); !errors.Is(err, ErrInvalidSlug) { t.Errorf("NormalizeSlug(%q) should be rejected, got %v", input, err) } } } func TestSaveReadDeleteDoc(t *testing.T) { t.Parallel() engine := newTestEngine(t) slug, err := engine.SaveDoc("Core Service API", "# Core Service API\n\nBody.\n") if err != nil { t.Fatalf("SaveDoc: %v", err) } if slug != "core-service-api" { t.Fatalf("expected core-service-api, got %q", slug) } if _, err := os.Stat(filepath.Join(engine.DocsDir(), "core-service-api.md")); err != nil { t.Fatalf("Markdown file missing: %v", err) } content, err := engine.ReadDoc("Core Service API") if err != nil { t.Fatalf("ReadDoc: %v", err) } if !strings.Contains(content, "Body.") { t.Fatalf("unexpected content: %q", content) } if _, err := engine.ReadDoc("never-saved"); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound, got %v", err) } if err := engine.DeleteDoc("Core Service API"); err != nil { t.Fatalf("DeleteDoc: %v", err) } if _, err := engine.ReadDoc("core-service-api"); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound after delete, got %v", err) } if err := engine.DeleteDoc("core-service-api"); !errors.Is(err, ErrNotFound) { t.Fatalf("expected ErrNotFound on a second delete, got %v", err) } } func TestDeleteDocCascadesComments(t *testing.T) { t.Parallel() engine := newTestEngine(t) if _, err := engine.SaveDoc("doomed", "# Doomed\n"); err != nil { t.Fatal(err) } if _, err := engine.AppendComment("doomed", CommentInput{Author: "a", Role: "user", Content: "hi"}); err != nil { t.Fatal(err) } commentPath := filepath.Join(engine.CommentsDir(), "doomed-comments.json") if _, err := os.Stat(commentPath); err != nil { t.Fatalf("expected the comment file to exist: %v", err) } if err := engine.DeleteDoc("doomed"); err != nil { t.Fatal(err) } if _, err := os.Stat(commentPath); !os.IsNotExist(err) { t.Fatalf("expected the comment file to be removed, stat returned %v", err) } } func TestSaveDocNormalizesLineEndings(t *testing.T) { t.Parallel() engine := newTestEngine(t) if _, err := engine.SaveDoc("crlf", "line one\r\nline two\r\n\r\n"); err != nil { t.Fatal(err) } content, err := engine.ReadDoc("crlf") if err != nil { t.Fatal(err) } if strings.Contains(content, "\r") { t.Fatalf("carriage returns survived: %q", content) } if content != "line one\nline two\n" { t.Fatalf("unexpected normalization: %q", content) } } func TestCommentBadges(t *testing.T) { t.Parallel() cases := map[Role]string{ RoleUser: "🟢", RoleOperator: "🔵", RoleDeveloper: "🔴", } for role, badge := range cases { if role.Badge() != badge { t.Errorf("role %s badge = %q, want %q", role, role.Badge(), badge) } if !role.Valid() { t.Errorf("role %s should be valid", role) } } if Role("admin").Badge() != "" || Role("admin").Valid() { t.Error("an unknown role must have no badge and be invalid") } for _, raw := range []string{"user", "USER", " User "} { if role, err := ParseRole(raw); err != nil || role != RoleUser { t.Errorf("ParseRole(%q) = %v, %v", raw, role, err) } } if _, err := ParseRole("root"); !errors.Is(err, ErrInvalidRole) { t.Errorf("expected ErrInvalidRole, got %v", err) } } func TestAppendCommentValidation(t *testing.T) { t.Parallel() engine := newTestEngine(t) if _, err := engine.AppendComment("valid", CommentInput{Role: "developer", Content: " "}); !errors.Is(err, ErrInvalidComment) { t.Errorf("blank content should be rejected, got %v", err) } if _, err := engine.AppendComment("valid", CommentInput{Role: "wizard", Content: "hello"}); !errors.Is(err, ErrInvalidRole) { t.Errorf("unknown role should be rejected, got %v", err) } if _, err := engine.AppendComment("../escape", CommentInput{Role: "user", Content: "hi"}); !errors.Is(err, ErrInvalidSlug) { t.Errorf("traversal slug should be rejected, got %v", err) } comment, err := engine.AppendComment("valid", CommentInput{Role: "user", Content: "hello"}) if err != nil { t.Fatalf("AppendComment: %v", err) } if comment.Author != "User" { t.Errorf("an omitted author should default to the role label, got %q", comment.Author) } if comment.Badge != "🟢" { t.Errorf("expected the user badge, got %q", comment.Badge) } if comment.ID == "" || comment.Timestamp.IsZero() { t.Error("expected an ID and a timestamp") } } func TestCommentsAreSortedAndDeduplicatedByID(t *testing.T) { t.Parallel() engine := newTestEngine(t) base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) for i, offset := range []int{30, 10, 20} { if _, err := engine.AppendComment("ordered", CommentInput{ Author: fmt.Sprintf("user-%d", i), Role: "user", Content: fmt.Sprintf("comment %d", i), Timestamp: base.Add(time.Duration(offset) * time.Minute), }); err != nil { t.Fatal(err) } } comments, err := engine.ListComments("ordered") if err != nil { t.Fatal(err) } if len(comments) != 3 { t.Fatalf("expected 3 comments, got %d", len(comments)) } for i := 1; i < len(comments); i++ { if comments[i].Timestamp.Before(comments[i-1].Timestamp) { t.Fatalf("comments are not in chronological order: %+v", comments) } } if strings.TrimSpace(comments[0].Content) != "comment 1" { t.Fatalf("expected the earliest comment first, got %q", comments[0].Content) } } func TestDeleteComment(t *testing.T) { t.Parallel() engine := newTestEngine(t) first, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "first"}) if err != nil { t.Fatal(err) } if _, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "second"}); err != nil { t.Fatal(err) } removed, err := engine.DeleteComment("prune", first.ID) if err != nil || !removed { t.Fatalf("expected the comment to be removed, got %v / %v", removed, err) } removed, err = engine.DeleteComment("prune", "does-not-exist") if err != nil || removed { t.Fatalf("deleting an unknown ID must be a no-op, got %v / %v", removed, err) } remaining, err := engine.ListComments("prune") if err != nil { t.Fatal(err) } if len(remaining) != 1 || strings.TrimSpace(remaining[0].Content) != "second" { t.Fatalf("unexpected remaining comments: %+v", remaining) } } func TestListAndSearchDocs(t *testing.T) { t.Parallel() engine := newTestEngine(t) for _, title := range []string{"Alpha Service", "Beta Service", "Gamma Gateway"} { if _, err := engine.SaveDoc(title, "# "+title+"\n"); err != nil { t.Fatal(err) } } if _, err := engine.AppendComment("alpha-service", CommentInput{Role: "user", Content: "hi"}); err != nil { t.Fatal(err) } all, err := engine.ListDocs() if err != nil { t.Fatal(err) } if len(all) != 3 { t.Fatalf("expected 3 documents, got %d", len(all)) } // Titles come from the document's H1, not from a slug-derived guess. for _, doc := range all { if doc.Title != "Alpha Service" && doc.Title != "Beta Service" && doc.Title != "Gamma Gateway" { t.Fatalf("document %s has title %q; expected the H1 heading", doc.Slug, doc.Title) } } detailed, err := engine.ListDocsDetailed() if err != nil { t.Fatal(err) } for _, doc := range detailed { if doc.Slug == "alpha-service" && doc.Comments != 1 { t.Fatalf("expected 1 comment on alpha-service, got %d", doc.Comments) } } hits, err := engine.Search("gateway") if err != nil { t.Fatal(err) } if len(hits) != 1 || hits[0].Slug != "gamma-gateway" { t.Fatalf("unexpected search hits: %+v", hits) } // Temp files left by an interrupted write must never surface in the index. if err := os.WriteFile(filepath.Join(engine.DocsDir(), ".godoc-tmp-123"), []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(engine.DocsDir(), "not-markdown.txt"), []byte("x"), 0o644); err != nil { t.Fatal(err) } all, err = engine.ListDocs() if err != nil { t.Fatal(err) } if len(all) != 3 { t.Fatalf("index must ignore temp and non-Markdown files, got %d entries", len(all)) } } func TestExtractTitleAndTitleize(t *testing.T) { t.Parallel() cases := []struct { content string slug string want string }{ {"# Core Service API\n\nbody", "core-service-api", "Core Service API"}, {"## Only an H2\n\nbody", "only-an-h2", "Only an H2"}, {"no heading here", "plain-slug", "Plain Slug"}, {"", "empty-slug", "Empty Slug"}, } for _, tc := range cases { if got := ExtractTitle(tc.content, tc.slug); got != tc.want { t.Errorf("ExtractTitle(%q, %q) = %q, want %q", tc.content, tc.slug, got, tc.want) } } } func TestConcurrentAppendsLoseNothing(t *testing.T) { t.Parallel() engine := newTestEngine(t) const ( writers = 50 perDoc = 40 ) var wg sync.WaitGroup errs := make(chan error, writers) for w := 0; w < writers; w++ { wg.Add(1) go func(worker int) { defer wg.Done() role := Roles()[worker%3] for n := 0; n < perDoc; n++ { if _, err := engine.AppendComment("hot", CommentInput{ Author: fmt.Sprintf("worker-%02d", worker), Role: string(role), Content: fmt.Sprintf("note %d from %d", n, worker), }); err != nil { errs <- err return } } }(w) } wg.Wait() close(errs) for err := range errs { t.Fatalf("concurrent append failed: %v", err) } comments, err := engine.ListComments("hot") if err != nil { t.Fatal(err) } if len(comments) != writers*perDoc { t.Fatalf("expected %d comments, got %d", writers*perDoc, len(comments)) } seen := make(map[string]struct{}, len(comments)) for _, comment := range comments { key := comment.Author + "|" + comment.Content if _, dup := seen[key]; dup { t.Fatalf("duplicate entry after concurrent writes: %s", key) } seen[key] = struct{}{} } } func TestConcurrentReadsNeverSeeTornWrites(t *testing.T) { t.Parallel() engine := newTestEngine(t) const marker = "SENTINEL-HEADER\n" body := strings.Repeat("payload line\n", 500) var wg sync.WaitGroup errs := make(chan error, 32) for w := 0; w < 16; w++ { wg.Add(1) go func(worker int) { defer wg.Done() for n := 0; n < 25; n++ { if _, err := engine.SaveDoc("live", marker+body); err != nil { errs <- err return } } }(w) } for r := 0; r < 16; r++ { wg.Add(1) go func() { defer wg.Done() for n := 0; n < 100; n++ { content, err := engine.ReadDoc("live") if err != nil { if !errors.Is(err, ErrNotFound) { errs <- err } return } if !strings.HasPrefix(content, marker) { errs <- fmt.Errorf("torn read: %q", content[:min(len(content), 40)]) return } } }() } wg.Wait() close(errs) for err := range errs { t.Fatalf("concurrent access failed: %v", err) } entries, err := os.ReadDir(engine.DocsDir()) if err != nil { t.Fatal(err) } for _, entry := range entries { if strings.HasPrefix(entry.Name(), ".godoc-tmp-") { t.Fatalf("atomic write leaked a temp file: %s", entry.Name()) } } } func TestStats(t *testing.T) { t.Parallel() engine := newTestEngine(t) if _, err := engine.SaveDoc("counted", "# Counted\n"); err != nil { t.Fatal(err) } for i, role := range []Role{RoleUser, RoleUser, RoleOperator, RoleDeveloper, RoleDeveloper, RoleDeveloper} { if _, err := engine.AppendComment("counted", CommentInput{ Author: fmt.Sprintf("author-%d", i), Role: string(role), Content: fmt.Sprintf("comment %d", i), }); err != nil { t.Fatal(err) } } stats, err := engine.Stats("counted") if err != nil { t.Fatal(err) } if stats.Total != 6 { t.Fatalf("expected 6 comments, got %d", stats.Total) } if stats.ByRole["developer"] != 3 || stats.ByRole["user"] != 2 || stats.ByRole["operator"] != 1 { t.Fatalf("unexpected tally: %+v", stats.ByRole) } if stats.MostActive != "developer" { t.Fatalf("expected developer as the most active role, got %q", stats.MostActive) } if stats.OldestAt == nil || stats.LatestAt == nil { t.Fatal("expected the time bounds to be populated") } } func TestEngineSatisfiesDocumentedInterface(t *testing.T) { t.Parallel() engine := newTestEngine(t) exists, err := engine.DocExists("Core Service API") if err != nil || exists { t.Fatalf("DocExists before save = %v / %v", exists, err) } if _, err := engine.SaveDoc("Core Service API", "# Core Service API\n"); err != nil { t.Fatal(err) } exists, err = engine.DocExists("core-service-api") if err != nil || !exists { t.Fatalf("DocExists after save = %v / %v", exists, err) } meta, err := engine.Stat("core-service-api") if err != nil { t.Fatal(err) } if meta.Title != "Core Service API" { t.Fatalf("Stat title = %q", meta.Title) } if meta.Size <= 0 || meta.Modified.IsZero() { t.Fatalf("Stat returned incomplete metadata: %+v", meta) } } func TestResolveRootHonoursEnvironment(t *testing.T) { dir := t.TempDir() t.Setenv("GO_DOC_DATA_DIR", dir) if got := ResolveRoot(); got != dir { t.Fatalf("ResolveRoot() = %q, want %q", got, dir) } if _, err := New(" "); err == nil { t.Fatal("New must reject an empty root") } }