// 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") }