go-doc/backend/tests/bdd/framework.go

286 lines
7.1 KiB
Go
Raw Permalink Normal View History

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