286 lines
7.1 KiB
Go
286 lines
7.1 KiB
Go
|
|
// 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")
|
||
|
|
}
|