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