initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
578
backend/storage/engine_test.go
Normal file
578
backend/storage/engine_test.go
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue