bruno+docs: status asserts on generated mirrors, OpenAPI spec, safe negative scenarios

Three follow-ups from the smoke run, all additive:

1. Status asserts: bruno_gen.go parses each doc's documented success
   status (## Response (2NN), 'Success is **201**', '**204 No Content**')
   and emits a runtime tests script (expect(res.status).to.eql(N)) on
   148 of 191 mirrors. GETs default to 200 (uniform across this API);
   mutating verbs stay assert-less unless documented — no guessed
   asserts. Verified live: asserts execute and fail loudly (deliberate
   999 probe), pass on real responses.

2. OpenAPI: the same -update run now also writes
   docs/developer/openapi.yaml (141 paths, tags from docs areas,
   security schemes per auth label, request examples literal, no
   invented schemas — the docs site remains the shape reference).
   Verify mode fails when the file is missing.

3. Scenarios: 7 safe negative scenarios rebuilt (auth registration
   matrix 409/403/400, invalid-uuid 400, unknown 404 x2, invalid range
   400) — all no-mutation, repeatable, run via bru: 7/7 tests pass.
   These replace a fraction of the deleted broken suite; mutations-
   heavy scenarios stay out until a design for setup chains exists.

created_at RFC3339 deliberately NOT done — it is the one item that
would break things (Android parses current formats; plan reserves it
as a compat decision).bru run of mutating mirrors also proven to touch
live data (a 'To Read' collection was created and deleted in testing);
only id-less GET mirrors and negative scenarios are safe for blanket
runs.

make test green; generation idempotent (byte-identical re-runs);
no stray smoke rows in users.
This commit is contained in:
John O'Keefe committed 2026-10-24 19:20:13 -04:00
1 parent 0c45137f5b
commit 983980d07a
166 files changed
+4040 -8

No files matched your search

+137 -8
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"sort"
"strings"
"time"
)
// Bruno mirror generation. The docs tree is canonical; bruno/ holds the
@@ -118,18 +119,44 @@ func brunoURL(doc APIDoc) string {
return "{{base_url}}" + path
}
func brunoDocTitle(doc APIDoc) string {
func brunoDocBody(doc APIDoc) string {
raw, err := os.ReadFile(filepath.Join(docsRoot, brunoRel(doc)))
if err == nil {
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
if err != nil {
return ""
}
return string(raw)
}
func brunoDocTitle(doc APIDoc) string {
for _, line := range strings.Split(brunoDocBody(doc), "\n") {
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
}
return doc.Frontmatter.Path
}
var brunoStatusRe = regexp.MustCompile(`(?:## Response \((\d{3})|Success is \*\*(\d{3})|\*\*(\d{3}) No Content)`)
// brunoSuccessStatus extracts the documented happy-path status code from
// the doc body ("## Response (201 Created)", "Success is **201**",
// "**204 No Content**"). ok=false when the doc declares none — no assert
// is emitted rather than guessing.
func brunoSuccessStatus(doc APIDoc) (int, bool) {
m := brunoStatusRe.FindStringSubmatch(brunoDocBody(doc))
if m == nil {
return 0, false
}
for _, g := range m[1:] {
if g != "" {
n := 0
fmt.Sscanf(g, "%d", &n)
return n, true
}
}
return 0, false
}
// yamlBlock emits a literal block scalar at the given indent.
func yamlBlock(indent int, text string) string {
pad := strings.Repeat(" ", indent)
@@ -163,9 +190,25 @@ func brunoYAML(doc APIDoc, seq int) ([]byte, error) {
b.WriteString(" headers:\n - key: Authorization\n value: Bearer {{device_token}}\n")
}
b.WriteString("settings:\n encodeUrl: true\n timeout: 0\n followRedirects: true\n maxRedirects: 5\n")
scripts := ""
if brunoScriptDocs[doc.Name()] {
b.WriteString("\nruntime:\n scripts:\n - type: after-response\n code: " +
yamlBlock(8, brunoTokenScript))
scripts += " - type: after-response\n code: " + yamlBlock(8, brunoTokenScript)
}
status, hasStatus := brunoSuccessStatus(doc)
// GET is uniformly 200 across this API; mutating verbs vary
// (201/202/204/200) and stay assert-less unless the doc declares one.
if !hasStatus && doc.Frontmatter.Method == "GET" {
status, hasStatus = 200, true
}
if hasStatus {
if scripts != "" {
scripts += "\n"
}
assert := fmt.Sprintf("test(\"status is %d\", function() {\n expect(res.status).to.eql(%d);\n});", status, status)
scripts += " - type: tests\n code: " + yamlBlock(8, assert)
}
if scripts != "" {
b.WriteString("\nruntime:\n scripts:\n" + scripts)
}
b.WriteString("\ndocs:\n content: " + yamlBlock(4, fmt.Sprintf(
"# %s\n\n`%s %s` — auth: %s.\n\nCanonical reference: docs/developer/api/%s (docs site → Developer → API). "+
@@ -175,6 +218,92 @@ func brunoYAML(doc APIDoc, seq int) ([]byte, error) {
return []byte(b.String()), nil
}
// updateOpenAPISpec writes docs/developer/openapi.yaml from the same doc
// set: paths, tags (docs area), summaries, auth schemes, and request
// examples. Lean by design — examples are literal, schemas stay out
// (the docs site remains the shape reference).
func updateOpenAPISpec(docs []APIDoc) error {
type op struct {
method string
doc APIDoc
}
paths := map[string][]op{}
for _, d := range docs {
p := brunoParamRe.ReplaceAllStringFunc(d.Frontmatter.Path, func(p string) string {
return "{" + strings.TrimPrefix(p, ":") + "}"
})
paths[p] = append(paths[p], op{strings.ToLower(d.Frontmatter.Method), d})
}
keys := make([]string, 0, len(paths))
for k := range paths {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("openapi: 3.0.3\n")
b.WriteString("info:\n title: Bookhoard API\n")
fmt.Fprintf(&b, " version: '%s'\n", time.Now().Format("2006.01.02"))
b.WriteString(" description: |\n Generated from the canonical API docs (docs/developer/api/**) by\n" +
" internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).\n" +
" The docs site is the shape reference; this file lists the surface.\n")
b.WriteString("servers:\n - url: /\n")
b.WriteString("components:\n securitySchemes:\n bearerAuth:\n type: http\n scheme: bearer\n bearerFormat: JWT\n")
b.WriteString("paths:\n")
for _, p := range keys {
fmt.Fprintf(&b, " %s:\n", mustYAMLString(p))
ops := paths[p]
sort.Slice(ops, func(i, j int) bool { return ops[i].method < ops[j].method })
for _, o := range ops {
fmt.Fprintf(&b, " %s:\n", o.method)
fmt.Fprintf(&b, " tags: [%s]\n", o.doc.Frontmatter.Area)
fmt.Fprintf(&b, " summary: %s\n", mustYAMLString(brunoDocTitle(o.doc)))
switch o.doc.Frontmatter.Auth {
case "none":
b.WriteString(" security: []\n")
case "device-token":
b.WriteString(" description: 'Device token: Bearer header (KOReader) or ?token= query (Kobo/OPDS).'\n")
default:
b.WriteString(" security:\n - bearerAuth: []\n")
}
if len(o.doc.ExampleRequest) > 0 {
b.WriteString(" requestBody:\n content:\n application/json:\n")
b.WriteString(" example: " + yamlBlock(14, string(o.doc.ExampleRequest)))
}
status, ok := brunoSuccessStatus(o.doc)
if !ok && o.doc.Frontmatter.Method == "GET" {
status = 200
}
if ok || o.doc.Frontmatter.Method == "GET" {
desc := "OK"
switch status {
case 201:
desc = "Created"
case 202:
desc = "Accepted"
case 204:
desc = "No Content"
}
fmt.Fprintf(&b, " responses:\n '%d':\n description: %s\n", status, desc)
} else {
b.WriteString(" responses:\n '200':\n description: Success\n")
}
}
}
full := filepath.Join(docsRoot, "..", "openapi.yaml")
return os.WriteFile(full, []byte(b.String()), 0o644)
}
// mustYAMLString renders s as a safely-quoted YAML scalar (JSON string
// syntax is valid YAML flow scalar syntax).
func mustYAMLString(s string) string {
out, err := json.Marshal(s)
if err != nil {
return `""`
}
return string(out)
}
// Name is the doc filename without extension (login.md → login).
func (d APIDoc) Name() string {
return strings.TrimSuffix(filepath.Base(d.RelPath), ".md")