initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

View file

@ -0,0 +1,295 @@
package bdd
import (
"strings"
"testing"
"go-doc/storage"
)
// TestFeatureSlugifiedMarkdownStorage covers:
//
// Given a new application title
// When it is saved
// Then it should be slugified and written to a Markdown file
func TestFeatureSlugifiedMarkdownStorage(t *testing.T) {
Run(t, Feature{
Name: "Slugified Markdown storage",
Scenarios: []Scenario{
{
Name: "a new application title is slugified and written to a Markdown file",
Given: Steps{
{Text: `a new application title "Core Service API"`, Do: func(c *Context) error {
return ExpectEqual(storage.Slugify("Core Service API"), "core-service-api", "slugified title")
}},
},
When: Steps{
{Text: "the title is saved", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nHandles billing.\n")
return err
}},
},
Then: Steps{
{Text: "the engine reports the canonical slug", Do: func(c *Context) error {
meta, err := c.Engine.Stat("Core Service API")
if err != nil {
return err
}
return ExpectEqual(meta.Slug, "core-service-api", "canonical slug")
}},
{Text: "data/docs/core-service-api.md exists", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("core-service-api"))
if err != nil {
return err
}
if !exists {
return errf("expected %s to exist", c.DocPath("core-service-api"))
}
return nil
}},
{Text: "the Markdown content is stored verbatim", Do: func(c *Context) error {
content, err := c.RawDoc("core-service-api")
if err != nil {
return err
}
return ExpectContains(content, "# Core Service API", "stored markdown")
}},
{Text: "no file is created for the un-slugified title", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("Core Service API"))
if err != nil {
return err
}
if exists {
return errf("a non-slugified filename was created")
}
return nil
}},
},
},
{
Name: "titles with punctuation, casing and diacritics are normalised",
Given: Steps{
{Text: "a batch of awkward application titles", Do: func(c *Context) error {
cases := map[string]string{
" Core Service API ": "core-service-api",
"Deploy & Rollback": "deploy-rollback",
"Überwachen der Dienste": "uberwachen-der-dienste",
"API_v2 (Internal)": "api-v2-internal",
"!!!": "",
"Release 2024/Q1 -- Notes": "release-2024-q1-notes",
"User Facing / End Users": "user-facing-end-users",
"already-a-canonical-slug-1": "already-a-canonical-slug-1",
}
for input, want := range cases {
if got := storage.Slugify(input); got != want {
return errf("Slugify(%q) = %q, want %q", input, got, want)
}
}
return nil
}},
},
When: Steps{
{Text: "each title is saved", Do: func(c *Context) error {
for _, title := range []string{"Deploy & Rollback", "Überwachen der Dienste", "API_v2 (Internal)"} {
if _, err := c.Engine.SaveDoc(title, "# "+title+"\n"); err != nil {
return err
}
}
return nil
}},
},
Then: Steps{
{Text: "each slugified filename exists on disk", Do: func(c *Context) error {
for _, slug := range []string{"deploy-rollback", "uberwachen-der-dienste", "api-v2-internal"} {
exists, err := c.FileExists(c.DocPath(slug))
if err != nil {
return err
}
if !exists {
return errf("expected data/docs/%s.md to exist", slug)
}
}
return nil
}},
},
},
{
Name: "saving through the API slugifies the slug taken from the URL",
Given: Steps{
{Text: "the API is running against a temporary data directory", Do: func(c *Context) error {
return nil
}},
},
When: Steps{
{Text: `a document is POSTed to /api/docs/Core Service API`, Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/Core%20Service%20API", "text/markdown", "# Core Service API\n",
)
if err != nil {
return err
}
if err := ExpectStatus(response, 201); err != nil {
return errf("%w (body: %s)", err, payload)
}
saved, err := Decode[struct {
Slug string `json:"slug"`
}](payload)
if err != nil {
return err
}
c.Remember("slug", saved.Slug)
return nil
}},
},
Then: Steps{
{Text: "the response reports the slugified slug", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[string]("slug"), "core-service-api", "response slug")
}},
{Text: "the Markdown file is on disk", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("core-service-api"))
if err != nil {
return err
}
if !exists {
return errf("data/docs/core-service-api.md was not created")
}
return nil
}},
{Text: "GET /api/docs/core-service-api returns the raw Markdown", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/core-service-api", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
if contentType := response.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/markdown") {
return errf("expected text/markdown, got %q", contentType)
}
return ExpectContains(string(payload), "# Core Service API", "markdown response")
}},
},
},
{
Name: "saving an existing document replaces its content wholesale",
Given: Steps{
{Text: "an existing document with two sections", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("release-runbook", "# Runbook\n\n## Deploy\n\nstep\n\n## Rollback\n\nstep\n")
return err
}},
},
When: Steps{
{Text: "the document is rewritten with a single section", Do: func(c *Context) error {
response, _, err := c.Do("POST", "/api/docs/release-runbook", "text/markdown", "# Runbook v2\n\n## Deploy only\n")
if err != nil {
return err
}
return ExpectStatus(response, 200)
}},
},
Then: Steps{
{Text: "the previous sections are gone", Do: func(c *Context) error {
content, err := c.RawDoc("release-runbook")
if err != nil {
return err
}
if strings.Contains(content, "## Rollback") {
return errf("stale section survived the update: %q", content)
}
return ExpectContains(content, "## Deploy only", "updated markdown")
}},
},
},
{
Name: "deleting a document removes the file and its feedback",
Given: Steps{
{Text: "a document that already has comments", Do: func(c *Context) error {
if _, err := c.Engine.SaveDoc("obsolete-service", "# Obsolete\n"); err != nil {
return err
}
_, err := c.Engine.AppendComment("obsolete-service", storage.CommentInput{
Author: "ana",
Role: "user",
Content: "is this still needed?",
})
return err
}},
},
When: Steps{
{Text: "DELETE /api/docs/obsolete-service is called", Do: func(c *Context) error {
response, _, err := c.Do("DELETE", "/api/docs/obsolete-service", "", "")
if err != nil {
return err
}
return ExpectStatus(response, 204)
}},
},
Then: Steps{
{Text: "the Markdown file is gone", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("obsolete-service"))
if err != nil {
return err
}
if exists {
return errf("data/docs/obsolete-service.md still exists")
}
return nil
}},
{Text: "the comment file is gone", Do: func(c *Context) error {
exists, err := c.FileExists(c.CommentsPath("obsolete-service"))
if err != nil {
return err
}
if exists {
return errf("the feedback file was not cascaded")
}
return nil
}},
{Text: "reading the deleted document returns 404", Do: func(c *Context) error {
response, _, err := c.Do("GET", "/api/docs/obsolete-service", "", "")
if err != nil {
return err
}
return ExpectStatus(response, 404)
}},
},
},
{
Name: "path traversal in a slug is rejected",
Given: Steps{
{Text: "a request for a traversal slug", Do: func(c *Context) error {
return nil
}},
},
When: Steps{
{Text: `POST /api/docs/..%2F..%2Fetc%2Fpasswd is sent`, Do: func(c *Context) error {
response, payload, err := c.Do("POST", "/api/docs/..%2F..%2Fetc%2Fpasswd", "text/markdown", "nope")
if err != nil {
return err
}
c.Remember("status", response.StatusCode)
c.Remember("body", string(payload))
return nil
}},
},
Then: Steps{
{Text: "the request is rejected with 400", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[int]("status"), 400, "status code")
}},
{Text: "nothing is written outside data/docs", Do: func(c *Context) error {
for _, name := range []string{"etc", "passwd", ".."} {
exists, err := c.FileExists(c.DocPath(name))
if err != nil {
return err
}
if exists {
return errf("unexpected file created for slug %q", name)
}
}
return nil
}},
},
},
},
})
}

View file

@ -0,0 +1,360 @@
package bdd
import (
"strings"
"testing"
"go-doc/storage"
)
// TestFeatureBadgedCommentInjection covers:
//
// Given an existing documentation page
// When a developer adds a comment with a developer badge
// Then it should be appended to the JSON array and rendered with a red visual badge
func TestFeatureBadgedCommentInjection(t *testing.T) {
Run(t, Feature{
Name: "Badged comment injection",
Scenarios: []Scenario{
{
Name: "a developer comment is appended to the JSON array with a red badge",
Given: Steps{
{Text: "an existing documentation page for Core Service API", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nBilling endpoints.\n")
return err
}},
},
When: Steps{
{Text: "a developer posts a comment through POST /api/docs/core-service-api/comments", Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/core-service-api/comments", "application/json",
`{"author":"ana","role":"Developer","content":"The retry budget is undocumented."}`,
)
if err != nil {
return err
}
if err := ExpectStatus(response, 201); err != nil {
return errf("%w (body: %s)", err, payload)
}
created, err := Decode[struct {
Comment storage.Comment `json:"comment"`
Badge string `json:"badge"`
}](payload)
if err != nil {
return err
}
c.Remember("comment", created.Comment)
c.Remember("badge", created.Badge)
return nil
}},
},
Then: Steps{
{Text: "the comment is stored with the developer role", Do: func(c *Context) error {
comment := c.RecallAs[storage.Comment]("comment")
return ExpectEqual(comment.Role, storage.RoleDeveloper, "stored role")
}},
{Text: "the comment carries the red 🔴 developer badge", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[string]("badge"), badgeContract[storage.RoleDeveloper], "response badge")
}},
{Text: "data/comments/core-service-api-comments.json is a JSON array containing it", Do: func(c *Context) error {
raw, err := c.RawJSON("core-service-api")
if err != nil {
return err
}
if !strings.HasPrefix(strings.TrimLeft(string(raw), " \n\t"), JSONArrayPrefix) {
return errf("feedback file is not a JSON array: %q", string(raw))
}
comments, err := c.RawComments("core-service-api")
if err != nil {
return err
}
if len(comments) != 1 {
return errf("expected 1 stored comment, got %d", len(comments))
}
stored := comments[0]
if stored.ID == "" {
return errf("stored comment has no ID")
}
if stored.Author != "ana" {
return errf("expected author ana, got %q", stored.Author)
}
if !strings.Contains(stored.Content, "retry budget") {
return errf("stored content mismatch: %q", stored.Content)
}
return ExpectEqual(stored.Badge, badgeContract[storage.RoleDeveloper], "stored badge")
}},
{Text: "the badge renders in the red developer colour #ef4444", Do: func(c *Context) error {
role := c.RecallAs[storage.Comment]("comment").Role
return ExpectEqual(role.Hex(), badgeHex[storage.RoleDeveloper], "badge colour")
}},
{Text: "GET /api/docs/core-service-api/comments returns the badged comment", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/core-service-api/comments", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
listed, err := Decode[struct {
Comments []storage.Comment `json:"comments"`
Count int `json:"count"`
}](payload)
if err != nil {
return err
}
if listed.Count != 1 {
return errf("expected count 1, got %d", listed.Count)
}
return ExpectEqual(listed.Comments[0].Badge, "🔴", "listed badge")
}},
},
},
{
Name: "all three audience badges are applied to their own roles",
Given: Steps{
{Text: "a documentation page with an existing user comment", Do: func(c *Context) error {
if _, err := c.Engine.SaveDoc("deploy-pipeline", "# Deploy Pipeline\n"); err != nil {
return err
}
_, err := appendComment(c, "deploy-pipeline", storage.RoleUser, "sam", "The wizard is confusing")
return err
}},
},
When: Steps{
{Text: "an operator and a developer comment are added", Do: func(c *Context) error {
if _, err := appendComment(c, "deploy-pipeline", storage.RoleOperator, "robin", "Rollback takes 20 minutes"); err != nil {
return err
}
_, err := appendComment(c, "deploy-pipeline", storage.RoleDeveloper, "kit", "Add a --dry-run flag")
return err
}},
},
Then: Steps{
{Text: "the JSON array holds all three comments in insertion order", Do: func(c *Context) error {
comments, err := c.RawComments("deploy-pipeline")
if err != nil {
return err
}
if len(comments) != 3 {
return errf("expected 3 comments, got %d", len(comments))
}
want := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
for i, role := range want {
if comments[i].Role != role {
return errf("comment %d: expected role %s, got %s", i, role, comments[i].Role)
}
if comments[i].Badge != badgeContract[role] {
return errf("comment %d: expected badge %s, got %s", i, badgeContract[role], comments[i].Badge)
}
}
return nil
}},
{Text: "each badge matches its role colour", Do: func(c *Context) error {
comments, err := c.RawComments("deploy-pipeline")
if err != nil {
return err
}
for _, comment := range comments {
if comment.Role.Hex() != badgeHex[comment.Role] {
return errf("unexpected colour for %s", comment.Role)
}
}
return nil
}},
{Text: "GET /api/docs/deploy-pipeline/stats aggregates by role", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/deploy-pipeline/stats", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
stats, err := Decode[storage.CommentStats](payload)
if err != nil {
return err
}
if stats.Total != 3 {
return errf("expected total 3, got %d", stats.Total)
}
for _, role := range []string{"user", "operator", "developer"} {
if stats.ByRole[role] != 1 {
return errf("expected 1 %s comment, got %d", role, stats.ByRole[role])
}
}
return nil
}},
},
},
{
Name: "the badge is derived server-side and cannot be spoofed by the client",
Given: Steps{
{Text: "a documentation page", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("trust-boundary", "# Trust Boundary\n")
return err
}},
},
When: Steps{
{Text: `a developer posts a comment claiming the user badge "🟢"`, Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/trust-boundary/comments", "application/json",
`{"author":"mallory","role":"developer","content":"I am a user, honest","badge":"🟢"}`,
)
if err != nil {
return err
}
if err := ExpectStatus(response, 201); err != nil {
return errf("%w (body: %s)", err, payload)
}
c.Remember("payload", string(payload))
return nil
}},
},
Then: Steps{
{Text: "the server overrides the claim with the developer badge", Do: func(c *Context) error {
comments, err := c.RawComments("trust-boundary")
if err != nil {
return err
}
if len(comments) != 1 {
return errf("expected 1 comment, got %d", len(comments))
}
return ExpectEqual(comments[0].Badge, "🔴", "server-assigned badge")
}},
},
},
{
Name: "an unknown role is rejected with a 400",
Given: Steps{
{Text: "a documentation page", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("role-guard", "# Role Guard\n")
return err
}},
},
When: Steps{
{Text: `a comment is posted with role "root"`, Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/role-guard/comments", "application/json",
`{"author":"mallory","role":"root","content":"let me in"}`,
)
if err != nil {
return err
}
c.Remember("status", response.StatusCode)
c.Remember("body", string(payload))
return nil
}},
},
Then: Steps{
{Text: "the API answers 400 with the invalid_role code", Do: func(c *Context) error {
if err := ExpectEqual(c.RecallAs[int]("status"), 400, "status"); err != nil {
return err
}
return ExpectContains(c.RecallAs[string]("body"), "invalid_role", "error body")
}},
{Text: "no feedback file is created", Do: func(c *Context) error {
exists, err := c.FileExists(c.CommentsPath("role-guard"))
if err != nil {
return err
}
if exists {
return errf("a feedback file was created for a rejected comment")
}
return nil
}},
},
},
{
Name: "an empty comment is rejected",
Given: Steps{
{Text: "a documentation page", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("empty-guard", "# Empty Guard\n")
return err
}},
},
When: Steps{
{Text: `a comment with blank content is posted`, Do: func(c *Context) error {
response, _, err := c.Do(
"POST", "/api/docs/empty-guard/comments", "application/json",
`{"author":"ana","role":"operator","content":" "}`,
)
if err != nil {
return err
}
c.Remember("status", response.StatusCode)
return nil
}},
},
Then: Steps{
{Text: "the API answers 400", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[int]("status"), 400, "status")
}},
{Text: "no feedback file is created", Do: func(c *Context) error {
exists, err := c.FileExists(c.CommentsPath("empty-guard"))
if err != nil {
return err
}
if exists {
return errf("a feedback file was created for a rejected comment")
}
return nil
}},
},
},
{
Name: "a comment on an unknown slug still creates the feedback file",
Given: Steps{
{Text: "no document exists for the slug new-service", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("new-service"))
if err != nil {
return err
}
if exists {
return errf("the document should not exist yet")
}
return nil
}},
},
When: Steps{
{Text: "the first user comment is posted for new-service", Do: func(c *Context) error {
response, _, err := c.Do(
"POST", "/api/docs/new-service/comments", "application/json",
`{"author":"jo","role":"user","content":"Is this documented?"}`,
)
if err != nil {
return err
}
return ExpectStatus(response, 201)
}},
},
Then: Steps{
{Text: "the feedback file holds the user badge 🟢", Do: func(c *Context) error {
comments, err := c.RawComments("new-service")
if err != nil {
return err
}
if len(comments) != 1 {
return errf("expected 1 comment, got %d", len(comments))
}
return ExpectEqual(comments[0].Badge, "🟢", "badge")
}},
{Text: "reading the comments of a slug with no feedback returns an empty list", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/never-seen/comments", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
listed, err := Decode[struct {
Comments []storage.Comment `json:"comments"`
}](payload)
if err != nil {
return err
}
return ExpectEqual(len(listed.Comments), 0, "comment count")
}},
},
},
},
})
}

View file

@ -0,0 +1,285 @@
// Package bdd is a dependency-free BDD harness for the go-doc backend.
//
// Features are declared as Gherkin-shaped Given/When/Then scenarios and executed
// against the real storage engine and a real HTTP server backed by a temporary
// directory on disk. There are no mocks and no fakes: every Then step asserts on
// bytes that were actually written to the filesystem.
package bdd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go-doc/router"
"go-doc/storage"
)
// Step is a single Gherkin clause. Text is used verbatim as the subtest name, so
// a failing run reads as a sentence.
type Step struct {
Text string
Do func(c *Context) error
}
func (s Step) run(c *Context) error {
if err := s.Do(c); err != nil {
return fmt.Errorf("%s: %w", s.Text, err)
}
return nil
}
// Steps is an ordered clause list.
type Steps []Step
func (s Steps) run(c *Context) error {
for _, step := range s {
if err := step.run(c); err != nil {
return err
}
}
return nil
}
// Scenario is one executable example.
type Scenario struct {
Name string
Given Steps
When Steps
Then Steps
}
// Feature groups the scenarios for one business capability.
type Feature struct {
Name string
Scenarios []Scenario
}
// Context carries the world a scenario runs against: a private data directory, a
// storage engine, and a live HTTP server. Steps share state through Remember and
// Recall so a When clause can hand data to a Then clause.
type Context struct {
T *testing.T
DataDir string
Engine *storage.Engine
Server *httptest.Server
Client *http.Client
// StartedAt timestamps scenario creation so load steps can assert a budget.
StartedAt time.Time
mu sync.Mutex
state map[string]any
}
// NewContext provisions an isolated data directory and a live API server.
func NewContext(t *testing.T) *Context {
t.Helper()
dataDir := t.TempDir()
engine, err := storage.New(dataDir)
if err != nil {
t.Fatalf("bdd: provision storage engine: %v", err)
}
handler, err := router.NewWithConfig(router.Config{
Engine: engine,
Logger: quietLogger(),
})
if err != nil {
t.Fatalf("bdd: build router: %v", err)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return &Context{
T: t,
StartedAt: time.Now(),
DataDir: dataDir,
Engine: engine,
Server: server,
Client: server.Client(),
state: make(map[string]any),
}
}
// Remember stores a value for later steps in the same scenario.
func (c *Context) Remember(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.state[key] = value
}
// Recall reads a remembered value, failing the step if it is absent or nil.
func (c *Context) Recall(key string) any {
c.mu.Lock()
defer c.mu.Unlock()
value, ok := c.state[key]
if !ok || value == nil {
c.T.Fatalf("bdd: scenario state has no value for %q", key)
}
return value
}
// RecallAs is Recall with a type assertion.
func (c *Context) RecallAs[T any](key string) T {
value, ok := c.Recall(key).(T)
if !ok {
c.T.Fatalf("bdd: state %q is %T, not the requested type", key, c.Recall(key))
}
return value
}
// DocPath is the on-disk location a slug must land on.
func (c *Context) DocPath(slug string) string {
return filepath.Join(c.DataDir, "docs", slug+".md")
}
// CommentsPath is the on-disk location a slug's feedback must land on.
func (c *Context) CommentsPath(slug string) string {
return filepath.Join(c.DataDir, "comments", slug+"-comments.json")
}
// RawDoc reads the Markdown file straight off disk, bypassing the API.
func (c *Context) RawDoc(slug string) (string, error) {
data, err := os.ReadFile(c.DocPath(slug))
return string(data), err
}
// RawComments decodes the JSON array straight off disk, bypassing the API.
func (c *Context) RawComments(slug string) ([]storage.Comment, error) {
data, err := os.ReadFile(c.CommentsPath(slug))
if err != nil {
return nil, err
}
var comments []storage.Comment
if err := json.Unmarshal(data, &comments); err != nil {
return nil, fmt.Errorf("feedback file is not a valid JSON array: %w", err)
}
return comments, nil
}
// RawJSON returns the undecoded bytes of the feedback file.
func (c *Context) RawJSON(slug string) ([]byte, error) {
return os.ReadFile(c.CommentsPath(slug))
}
// Do performs an HTTP request against the live server and returns the response
// together with its body.
func (c *Context) Do(method, path, contentType string, body string) (*http.Response, []byte, error) {
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
request, err := http.NewRequest(method, c.Server.URL+path, reader)
if err != nil {
return nil, nil, err
}
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
response, err := c.Client.Do(request)
if err != nil {
return nil, nil, err
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
return response, nil, err
}
return response, payload, nil
}
// Decode unmarshals a JSON response body.
func Decode[T any](payload []byte) (T, error) {
var out T
err := json.Unmarshal(payload, &out)
return out, err
}
// ExpectStatus fails the step unless the response carries the wanted status.
func ExpectStatus(response *http.Response, want int) error {
if response == nil {
return fmt.Errorf("no response")
}
if response.StatusCode != want {
return fmt.Errorf("expected HTTP %d, got %d", want, response.StatusCode)
}
return nil
}
// ExpectContains fails the step unless haystack contains needle.
func ExpectContains(haystack, needle, label string) error {
if !strings.Contains(haystack, needle) {
return fmt.Errorf("%s: expected to contain %q, got %q", label, needle, haystack)
}
return nil
}
// ExpectEqual fails the step unless got equals want.
func ExpectEqual[T comparable](got, want T, label string) error {
if got != want {
return fmt.Errorf("%s: expected %v, got %v", label, want, got)
}
return nil
}
// FileExists reports whether path is present on disk.
func (c *Context) FileExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// Run executes a feature: every scenario gets a fresh Context, and every clause
// becomes its own subtest so failures point at the exact sentence that broke.
func Run(t *testing.T, feature Feature) {
t.Helper()
t.Run(feature.Name, func(t *testing.T) {
for _, scenario := range feature.Scenarios {
scenario := scenario
t.Run(scenario.Name, func(t *testing.T) {
c := NewContext(t)
phase(t, "Given", c, scenario.Given)
phase(t, "When", c, scenario.When)
phase(t, "Then", c, scenario.Then)
})
}
})
}
func phase(t *testing.T, keyword string, c *Context, steps Steps) {
t.Helper()
for _, step := range steps {
t.Run(keyword+" "+step.Text, func(t *testing.T) {
if err := step.Do(c); err != nil {
t.Fatalf("%s %s\n%s", keyword, step.Text, indent(err.Error()))
}
})
}
}
func indent(text string) string {
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
for i, line := range lines {
lines[i] = " " + line
}
return strings.Join(lines, "\n")
}

View file

@ -0,0 +1,61 @@
package bdd
import (
"fmt"
"os"
"go-doc/storage"
)
func errf(format string, args ...any) error {
return fmt.Errorf(format, args...)
}
// Badge assertions shared by the feedback features, keyed to the role -> badge
// contract mandated by the specification: User 🟢, Operator 🔵, Developer 🔴.
var badgeContract = map[storage.Role]string{
storage.RoleUser: "🟢",
storage.RoleOperator: "🔵",
storage.RoleDeveloper: "🔴",
}
// Hex colours the frontend mirrors when it renders the badge.
var badgeHex = map[storage.Role]string{
storage.RoleUser: "#22c55e",
storage.RoleOperator: "#3b82f6",
storage.RoleDeveloper: "#ef4444",
}
// JSONArrayPrefix is the first byte of the on-disk feedback file. The spec
// requires the file to *be* the JSON array, so every write starts with "[".
const JSONArrayPrefix = `[`
func appendComment(c *Context, slug string, role storage.Role, author, content string) (storage.Comment, error) {
return c.Engine.AppendComment(slug, storage.CommentInput{
Author: author,
Role: string(role),
Content: content,
})
}
// listDir returns the entry names of a directory, for temp-file leak checks.
func listDir(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
return names, nil
}
// truncate shortens a document for readable failure output.
func truncate(text string) string {
const limit = 120
if len(text) <= limit {
return text
}
return text[:limit] + "…"
}

View file

@ -0,0 +1,417 @@
package bdd
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"go-doc/storage"
)
// TestFeatureFileUpdatesUnderLoad covers:
//
// Given many documents and thousands of comments in flight
// When they are written concurrently through the API
// Then every update lands on disk with no torn or lost writes
func TestFeatureFileUpdatesUnderLoad(t *testing.T) {
Run(t, Feature{
Name: "File updates under load",
Scenarios: []Scenario{
{
Name: "concurrent comment injection loses no entry",
Given: Steps{
{Text: "a documentation page with 400 concurrent feedback writers", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("hot-service", "# Hot Service\n\n## For Operators\n\nScaling notes.\n")
return err
}},
},
When: Steps{
{Text: "400 comments are appended concurrently across all three roles", Do: func(c *Context) error {
const writers = 400
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
var wg sync.WaitGroup
errs := make(chan error, writers)
start := make(chan struct{})
for i := 0; i < writers; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
<-start // release every writer at once
role := roles[index%len(roles)]
if _, err := appendComment(c, "hot-service", role,
fmt.Sprintf("user-%03d", index),
fmt.Sprintf("finding number %d", index),
); err != nil {
errs <- err
}
}(i)
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
Then: Steps{
{Text: "all 400 comments are present in the JSON array", Do: func(c *Context) error {
comments, err := c.RawComments("hot-service")
if err != nil {
return err
}
if len(comments) != 400 {
return errf("expected 400 persisted comments, got %d", len(comments))
}
return nil
}},
{Text: "no comment was lost to a read-modify-write race", Do: func(c *Context) error {
comments, err := c.RawComments("hot-service")
if err != nil {
return err
}
seen := make(map[string]struct{}, len(comments))
for _, comment := range comments {
key := fmt.Sprintf("%s|%s", comment.Author, comment.Content)
if _, duplicate := seen[key]; duplicate {
return errf("duplicate entry survived: %s", key)
}
seen[key] = struct{}{}
}
if len(seen) != 400 {
return errf("expected 400 distinct entries, got %d", len(seen))
}
return nil
}},
{Text: "every comment carries the badge for its own role", Do: func(c *Context) error {
comments, err := c.RawComments("hot-service")
if err != nil {
return err
}
for _, comment := range comments {
if comment.Badge != badgeContract[comment.Role] {
return errf("comment %s has badge %q, want %q",
comment.ID, comment.Badge, badgeContract[comment.Role])
}
}
return nil
}},
{Text: "the file on disk is a single valid JSON array, never a torn write", Do: func(c *Context) error {
raw, err := c.RawJSON("hot-service")
if err != nil {
return err
}
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") ||
!strings.HasSuffix(strings.TrimSpace(string(raw)), "]") {
return errf("feedback file is not a well-formed JSON array")
}
var decoded []storage.Comment
if err := json.Unmarshal(raw, &decoded); err != nil {
return errf("feedback file failed to decode: %w", err)
}
return ExpectEqual(len(decoded), 400, "decoded comment count")
}},
{Text: "the HTTP read path agrees with the file on disk", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/hot-service/comments", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
listed, err := Decode[struct {
Count int `json:"count"`
}](payload)
if err != nil {
return err
}
return ExpectEqual(listed.Count, 400, "API comment count")
}},
},
},
{
Name: "readers never observe a partially written document while writers stream updates",
Given: Steps{
{Text: "a document with a long body", Do: func(c *Context) error {
body := strings.Repeat("Sentinel paragraph for torn-read detection.\n\n", 400)
_, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body)
return err
}},
},
When: Steps{
{Text: "20 writers and 40 readers hammer the API concurrently", Do: func(c *Context) error {
const (
writers = 20
readers = 40
rounds = 25
)
var wg sync.WaitGroup
errs := make(chan error, writers+readers)
for w := 0; w < writers; w++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
for round := 0; round < rounds; round++ {
body := strings.Repeat(
fmt.Sprintf("writer %d round %d payload line.\n\n", index, round), 200)
if _, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body); err != nil {
errs <- err
return
}
}
}(w)
}
for r := 0; r < readers; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for round := 0; round < rounds*4; round++ {
content, err := c.Engine.ReadDoc("live-doc")
if err != nil {
errs <- err
return
}
if !strings.HasPrefix(content, "# Live Doc\n\n") {
errs <- errf("reader saw a truncated document header: %q", truncate(content))
return
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
Then: Steps{
{Text: "every read returned a complete document", Do: func(c *Context) error {
content, err := c.RawDoc("live-doc")
if err != nil {
return err
}
if !strings.HasPrefix(content, "# Live Doc\n\n") {
return errf("final document is malformed")
}
return nil
}},
{Text: "no temporary file was left behind in data/docs", Do: func(c *Context) error {
entries, err := listDir(c.DataDir + "/docs")
if err != nil {
return err
}
for _, name := range entries {
if strings.HasPrefix(name, ".godoc-tmp-") {
return errf("atomic-write temp file leaked: %s", name)
}
}
return nil
}},
},
},
{
Name: "many documents can be seeded in parallel without cross-contamination",
Given: Steps{
{Text: "120 documents seeded across 8 workers with 25 comments each", Do: func(c *Context) error {
const (
docs = 120
perDoc = 25
numWorkers = 8
)
var wg sync.WaitGroup
errs := make(chan error, numWorkers)
jobs := make(chan int, docs)
for i := 0; i < docs; i++ {
jobs <- i
}
close(jobs)
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
for index := range jobs {
title := fmt.Sprintf("Service Suite %03d Overview", index)
slug := storage.Slugify(title)
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n",
title)
if _, err := c.Engine.SaveDoc(title, body); err != nil {
errs <- err
return
}
for n := 0; n < perDoc; n++ {
role := roles[n%len(roles)]
if _, err := c.Engine.AppendComment(slug, storage.CommentInput{
Author: fmt.Sprintf("w%d-u%d", worker, n),
Role: string(role),
Content: fmt.Sprintf("note %d for %s", n, slug),
}); err != nil {
errs <- err
return
}
}
}
}(w)
}
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
When: Steps{
{Text: "the document index is read back", Do: func(c *Context) error {
return nil
}},
},
Then: Steps{
{Text: "all 120 slugified documents are indexed", Do: func(c *Context) error {
metas, err := c.Engine.ListDocs()
if err != nil {
return err
}
return ExpectEqual(len(metas), 120, "indexed document count")
}},
{Text: "each document owns exactly its own 25 comments", Do: func(c *Context) error {
for index := 0; index < 120; index++ {
slug := storage.Slugify(fmt.Sprintf("Service Suite %03d Overview", index))
comments, err := c.RawComments(slug)
if err != nil {
return err
}
if len(comments) != 25 {
return errf("%s: expected 25 comments, got %d", slug, len(comments))
}
for _, comment := range comments {
if !strings.Contains(comment.Content, slug) {
return errf("%s: comment from another document leaked in: %q", slug, comment.Content)
}
}
}
return nil
}},
{Text: "the aggregate stats total 3000 comments", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/stats", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
stats, err := Decode[struct {
Docs int `json:"docs"`
Comments int `json:"comments"`
}](payload)
if err != nil {
return err
}
if stats.Docs != 120 {
return errf("expected 120 docs, got %d", stats.Docs)
}
return ExpectEqual(stats.Comments, 3000, "aggregate comment count")
}},
},
},
{
Name: "a burst of HTTP comment posts is durable",
Given: Steps{
{Text: "a documentation page served over HTTP", Do: func(c *Context) error {
response, _, err := c.Do("POST", "/api/docs/http-burst", "text/markdown", "# HTTP Burst\n")
if err != nil {
return err
}
return ExpectStatus(response, 201)
}},
},
When: Steps{
{Text: "200 concurrent POSTs hit /api/docs/http-burst/comments", Do: func(c *Context) error {
const posts = 200
var wg sync.WaitGroup
errs := make(chan error, posts)
for i := 0; i < posts; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
role := []string{"user", "operator", "developer"}[index%3]
body := fmt.Sprintf(`{"author":"burst-%03d","role":"%s","content":"burst comment %d"}`, index, role, index)
response, payload, err := c.Do("POST", "/api/docs/http-burst/comments", "application/json", body)
if err != nil {
errs <- err
return
}
if response.StatusCode != 201 {
errs <- errf("POST %d returned %d: %s", index, response.StatusCode, payload)
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
Then: Steps{
{Text: "the feedback file holds all 200 posts", Do: func(c *Context) error {
comments, err := c.RawComments("http-burst")
if err != nil {
return err
}
if len(comments) != 200 {
return errf("expected 200 persisted comments, got %d", len(comments))
}
return nil
}},
{Text: "every comment has a unique ID and a role badge", Do: func(c *Context) error {
comments, err := c.RawComments("http-burst")
if err != nil {
return err
}
ids := make(map[string]struct{}, len(comments))
for _, comment := range comments {
if _, duplicate := ids[comment.ID]; duplicate {
return errf("duplicate comment ID %s", comment.ID)
}
ids[comment.ID] = struct{}{}
if comment.Badge == "" {
return errf("comment %s has no badge", comment.ID)
}
if comment.Timestamp.IsZero() {
return errf("comment %s has no timestamp", comment.ID)
}
}
return nil
}},
{Text: "the write completes well inside a test timeout", Do: func(c *Context) error {
// Guards against a regression to unbounded fsync storms: the whole
// scenario must finish inside the 10 minute suite budget.
if elapsed := time.Since(c.StartedAt); elapsed > 2*time.Minute {
return errf("burst scenario took %s", elapsed)
}
return nil
}},
},
},
},
})
}

View file

@ -0,0 +1,12 @@
package bdd
import (
"io"
"log/slog"
)
// quietLogger keeps the BDD output readable: the harness reports failures itself,
// so the router's per-request access log is discarded.
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}