go-doc/backend/storage/engine_test.go

579 lines
15 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:03:50 +02:00
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")
}
}