initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
295
backend/tests/bdd/documents.feature_test.go
Normal file
295
backend/tests/bdd/documents.feature_test.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// TestFeatureSlugifiedMarkdownStorage covers:
|
||||
//
|
||||
// Given a new application title
|
||||
// When it is saved
|
||||
// Then it should be slugified and written to a Markdown file
|
||||
func TestFeatureSlugifiedMarkdownStorage(t *testing.T) {
|
||||
Run(t, Feature{
|
||||
Name: "Slugified Markdown storage",
|
||||
Scenarios: []Scenario{
|
||||
{
|
||||
Name: "a new application title is slugified and written to a Markdown file",
|
||||
Given: Steps{
|
||||
{Text: `a new application title "Core Service API"`, Do: func(c *Context) error {
|
||||
return ExpectEqual(storage.Slugify("Core Service API"), "core-service-api", "slugified title")
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the title is saved", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nHandles billing.\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the engine reports the canonical slug", Do: func(c *Context) error {
|
||||
meta, err := c.Engine.Stat("Core Service API")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(meta.Slug, "core-service-api", "canonical slug")
|
||||
}},
|
||||
{Text: "data/docs/core-service-api.md exists", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("core-service-api"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errf("expected %s to exist", c.DocPath("core-service-api"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the Markdown content is stored verbatim", Do: func(c *Context) error {
|
||||
content, err := c.RawDoc("core-service-api")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectContains(content, "# Core Service API", "stored markdown")
|
||||
}},
|
||||
{Text: "no file is created for the un-slugified title", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("Core Service API"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("a non-slugified filename was created")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "titles with punctuation, casing and diacritics are normalised",
|
||||
Given: Steps{
|
||||
{Text: "a batch of awkward application titles", Do: func(c *Context) error {
|
||||
cases := map[string]string{
|
||||
" Core Service API ": "core-service-api",
|
||||
"Deploy & Rollback": "deploy-rollback",
|
||||
"Überwachen der Dienste": "uberwachen-der-dienste",
|
||||
"API_v2 (Internal)": "api-v2-internal",
|
||||
"!!!": "",
|
||||
"Release 2024/Q1 -- Notes": "release-2024-q1-notes",
|
||||
"User Facing / End Users": "user-facing-end-users",
|
||||
"already-a-canonical-slug-1": "already-a-canonical-slug-1",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := storage.Slugify(input); got != want {
|
||||
return errf("Slugify(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "each title is saved", Do: func(c *Context) error {
|
||||
for _, title := range []string{"Deploy & Rollback", "Überwachen der Dienste", "API_v2 (Internal)"} {
|
||||
if _, err := c.Engine.SaveDoc(title, "# "+title+"\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "each slugified filename exists on disk", Do: func(c *Context) error {
|
||||
for _, slug := range []string{"deploy-rollback", "uberwachen-der-dienste", "api-v2-internal"} {
|
||||
exists, err := c.FileExists(c.DocPath(slug))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errf("expected data/docs/%s.md to exist", slug)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "saving through the API slugifies the slug taken from the URL",
|
||||
Given: Steps{
|
||||
{Text: "the API is running against a temporary data directory", Do: func(c *Context) error {
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a document is POSTed to /api/docs/Core Service API`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/Core%20Service%20API", "text/markdown", "# Core Service API\n",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 201); err != nil {
|
||||
return errf("%w (body: %s)", err, payload)
|
||||
}
|
||||
saved, err := Decode[struct {
|
||||
Slug string `json:"slug"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("slug", saved.Slug)
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the response reports the slugified slug", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[string]("slug"), "core-service-api", "response slug")
|
||||
}},
|
||||
{Text: "the Markdown file is on disk", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("core-service-api"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errf("data/docs/core-service-api.md was not created")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "GET /api/docs/core-service-api returns the raw Markdown", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/core-service-api", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if contentType := response.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/markdown") {
|
||||
return errf("expected text/markdown, got %q", contentType)
|
||||
}
|
||||
return ExpectContains(string(payload), "# Core Service API", "markdown response")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "saving an existing document replaces its content wholesale",
|
||||
Given: Steps{
|
||||
{Text: "an existing document with two sections", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("release-runbook", "# Runbook\n\n## Deploy\n\nstep\n\n## Rollback\n\nstep\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the document is rewritten with a single section", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("POST", "/api/docs/release-runbook", "text/markdown", "# Runbook v2\n\n## Deploy only\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 200)
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the previous sections are gone", Do: func(c *Context) error {
|
||||
content, err := c.RawDoc("release-runbook")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(content, "## Rollback") {
|
||||
return errf("stale section survived the update: %q", content)
|
||||
}
|
||||
return ExpectContains(content, "## Deploy only", "updated markdown")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "deleting a document removes the file and its feedback",
|
||||
Given: Steps{
|
||||
{Text: "a document that already has comments", Do: func(c *Context) error {
|
||||
if _, err := c.Engine.SaveDoc("obsolete-service", "# Obsolete\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.Engine.AppendComment("obsolete-service", storage.CommentInput{
|
||||
Author: "ana",
|
||||
Role: "user",
|
||||
Content: "is this still needed?",
|
||||
})
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "DELETE /api/docs/obsolete-service is called", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("DELETE", "/api/docs/obsolete-service", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 204)
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the Markdown file is gone", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("obsolete-service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("data/docs/obsolete-service.md still exists")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the comment file is gone", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.CommentsPath("obsolete-service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("the feedback file was not cascaded")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "reading the deleted document returns 404", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("GET", "/api/docs/obsolete-service", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 404)
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "path traversal in a slug is rejected",
|
||||
Given: Steps{
|
||||
{Text: "a request for a traversal slug", Do: func(c *Context) error {
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `POST /api/docs/..%2F..%2Fetc%2Fpasswd is sent`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("POST", "/api/docs/..%2F..%2Fetc%2Fpasswd", "text/markdown", "nope")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("status", response.StatusCode)
|
||||
c.Remember("body", string(payload))
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the request is rejected with 400", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[int]("status"), 400, "status code")
|
||||
}},
|
||||
{Text: "nothing is written outside data/docs", Do: func(c *Context) error {
|
||||
for _, name := range []string{"etc", "passwd", ".."} {
|
||||
exists, err := c.FileExists(c.DocPath(name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("unexpected file created for slug %q", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
360
backend/tests/bdd/feedback.feature_test.go
Normal file
360
backend/tests/bdd/feedback.feature_test.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// TestFeatureBadgedCommentInjection covers:
|
||||
//
|
||||
// Given an existing documentation page
|
||||
// When a developer adds a comment with a developer badge
|
||||
// Then it should be appended to the JSON array and rendered with a red visual badge
|
||||
func TestFeatureBadgedCommentInjection(t *testing.T) {
|
||||
Run(t, Feature{
|
||||
Name: "Badged comment injection",
|
||||
Scenarios: []Scenario{
|
||||
{
|
||||
Name: "a developer comment is appended to the JSON array with a red badge",
|
||||
Given: Steps{
|
||||
{Text: "an existing documentation page for Core Service API", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nBilling endpoints.\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "a developer posts a comment through POST /api/docs/core-service-api/comments", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/core-service-api/comments", "application/json",
|
||||
`{"author":"ana","role":"Developer","content":"The retry budget is undocumented."}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 201); err != nil {
|
||||
return errf("%w (body: %s)", err, payload)
|
||||
}
|
||||
created, err := Decode[struct {
|
||||
Comment storage.Comment `json:"comment"`
|
||||
Badge string `json:"badge"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("comment", created.Comment)
|
||||
c.Remember("badge", created.Badge)
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the comment is stored with the developer role", Do: func(c *Context) error {
|
||||
comment := c.RecallAs[storage.Comment]("comment")
|
||||
return ExpectEqual(comment.Role, storage.RoleDeveloper, "stored role")
|
||||
}},
|
||||
{Text: "the comment carries the red 🔴 developer badge", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[string]("badge"), badgeContract[storage.RoleDeveloper], "response badge")
|
||||
}},
|
||||
{Text: "data/comments/core-service-api-comments.json is a JSON array containing it", Do: func(c *Context) error {
|
||||
raw, err := c.RawJSON("core-service-api")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimLeft(string(raw), " \n\t"), JSONArrayPrefix) {
|
||||
return errf("feedback file is not a JSON array: %q", string(raw))
|
||||
}
|
||||
comments, err := c.RawComments("core-service-api")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
return errf("expected 1 stored comment, got %d", len(comments))
|
||||
}
|
||||
stored := comments[0]
|
||||
if stored.ID == "" {
|
||||
return errf("stored comment has no ID")
|
||||
}
|
||||
if stored.Author != "ana" {
|
||||
return errf("expected author ana, got %q", stored.Author)
|
||||
}
|
||||
if !strings.Contains(stored.Content, "retry budget") {
|
||||
return errf("stored content mismatch: %q", stored.Content)
|
||||
}
|
||||
return ExpectEqual(stored.Badge, badgeContract[storage.RoleDeveloper], "stored badge")
|
||||
}},
|
||||
{Text: "the badge renders in the red developer colour #ef4444", Do: func(c *Context) error {
|
||||
role := c.RecallAs[storage.Comment]("comment").Role
|
||||
return ExpectEqual(role.Hex(), badgeHex[storage.RoleDeveloper], "badge colour")
|
||||
}},
|
||||
{Text: "GET /api/docs/core-service-api/comments returns the badged comment", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/core-service-api/comments", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
listed, err := Decode[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
Count int `json:"count"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if listed.Count != 1 {
|
||||
return errf("expected count 1, got %d", listed.Count)
|
||||
}
|
||||
return ExpectEqual(listed.Comments[0].Badge, "🔴", "listed badge")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "all three audience badges are applied to their own roles",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page with an existing user comment", Do: func(c *Context) error {
|
||||
if _, err := c.Engine.SaveDoc("deploy-pipeline", "# Deploy Pipeline\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := appendComment(c, "deploy-pipeline", storage.RoleUser, "sam", "The wizard is confusing")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "an operator and a developer comment are added", Do: func(c *Context) error {
|
||||
if _, err := appendComment(c, "deploy-pipeline", storage.RoleOperator, "robin", "Rollback takes 20 minutes"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := appendComment(c, "deploy-pipeline", storage.RoleDeveloper, "kit", "Add a --dry-run flag")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the JSON array holds all three comments in insertion order", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("deploy-pipeline")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 3 {
|
||||
return errf("expected 3 comments, got %d", len(comments))
|
||||
}
|
||||
want := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||
for i, role := range want {
|
||||
if comments[i].Role != role {
|
||||
return errf("comment %d: expected role %s, got %s", i, role, comments[i].Role)
|
||||
}
|
||||
if comments[i].Badge != badgeContract[role] {
|
||||
return errf("comment %d: expected badge %s, got %s", i, badgeContract[role], comments[i].Badge)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "each badge matches its role colour", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("deploy-pipeline")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, comment := range comments {
|
||||
if comment.Role.Hex() != badgeHex[comment.Role] {
|
||||
return errf("unexpected colour for %s", comment.Role)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "GET /api/docs/deploy-pipeline/stats aggregates by role", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/deploy-pipeline/stats", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := Decode[storage.CommentStats](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stats.Total != 3 {
|
||||
return errf("expected total 3, got %d", stats.Total)
|
||||
}
|
||||
for _, role := range []string{"user", "operator", "developer"} {
|
||||
if stats.ByRole[role] != 1 {
|
||||
return errf("expected 1 %s comment, got %d", role, stats.ByRole[role])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "the badge is derived server-side and cannot be spoofed by the client",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("trust-boundary", "# Trust Boundary\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a developer posts a comment claiming the user badge "🟢"`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/trust-boundary/comments", "application/json",
|
||||
`{"author":"mallory","role":"developer","content":"I am a user, honest","badge":"🟢"}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 201); err != nil {
|
||||
return errf("%w (body: %s)", err, payload)
|
||||
}
|
||||
c.Remember("payload", string(payload))
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the server overrides the claim with the developer badge", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("trust-boundary")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
return errf("expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
return ExpectEqual(comments[0].Badge, "🔴", "server-assigned badge")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "an unknown role is rejected with a 400",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("role-guard", "# Role Guard\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a comment is posted with role "root"`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/role-guard/comments", "application/json",
|
||||
`{"author":"mallory","role":"root","content":"let me in"}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("status", response.StatusCode)
|
||||
c.Remember("body", string(payload))
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the API answers 400 with the invalid_role code", Do: func(c *Context) error {
|
||||
if err := ExpectEqual(c.RecallAs[int]("status"), 400, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectContains(c.RecallAs[string]("body"), "invalid_role", "error body")
|
||||
}},
|
||||
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.CommentsPath("role-guard"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("a feedback file was created for a rejected comment")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "an empty comment is rejected",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("empty-guard", "# Empty Guard\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a comment with blank content is posted`, Do: func(c *Context) error {
|
||||
response, _, err := c.Do(
|
||||
"POST", "/api/docs/empty-guard/comments", "application/json",
|
||||
`{"author":"ana","role":"operator","content":" "}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("status", response.StatusCode)
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the API answers 400", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[int]("status"), 400, "status")
|
||||
}},
|
||||
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.CommentsPath("empty-guard"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("a feedback file was created for a rejected comment")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "a comment on an unknown slug still creates the feedback file",
|
||||
Given: Steps{
|
||||
{Text: "no document exists for the slug new-service", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("new-service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("the document should not exist yet")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the first user comment is posted for new-service", Do: func(c *Context) error {
|
||||
response, _, err := c.Do(
|
||||
"POST", "/api/docs/new-service/comments", "application/json",
|
||||
`{"author":"jo","role":"user","content":"Is this documented?"}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 201)
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the feedback file holds the user badge 🟢", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("new-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
return errf("expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
return ExpectEqual(comments[0].Badge, "🟢", "badge")
|
||||
}},
|
||||
{Text: "reading the comments of a slug with no feedback returns an empty list", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/never-seen/comments", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
listed, err := Decode[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(len(listed.Comments), 0, "comment count")
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
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")
|
||||
}
|
||||
61
backend/tests/bdd/helpers_test.go
Normal file
61
backend/tests/bdd/helpers_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func errf(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Badge assertions shared by the feedback features, keyed to the role -> badge
|
||||
// contract mandated by the specification: User 🟢, Operator 🔵, Developer 🔴.
|
||||
var badgeContract = map[storage.Role]string{
|
||||
storage.RoleUser: "🟢",
|
||||
storage.RoleOperator: "🔵",
|
||||
storage.RoleDeveloper: "🔴",
|
||||
}
|
||||
|
||||
// Hex colours the frontend mirrors when it renders the badge.
|
||||
var badgeHex = map[storage.Role]string{
|
||||
storage.RoleUser: "#22c55e",
|
||||
storage.RoleOperator: "#3b82f6",
|
||||
storage.RoleDeveloper: "#ef4444",
|
||||
}
|
||||
|
||||
// JSONArrayPrefix is the first byte of the on-disk feedback file. The spec
|
||||
// requires the file to *be* the JSON array, so every write starts with "[".
|
||||
const JSONArrayPrefix = `[`
|
||||
|
||||
func appendComment(c *Context, slug string, role storage.Role, author, content string) (storage.Comment, error) {
|
||||
return c.Engine.AppendComment(slug, storage.CommentInput{
|
||||
Author: author,
|
||||
Role: string(role),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// listDir returns the entry names of a directory, for temp-file leak checks.
|
||||
func listDir(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// truncate shortens a document for readable failure output.
|
||||
func truncate(text string) string {
|
||||
const limit = 120
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
return text[:limit] + "…"
|
||||
}
|
||||
417
backend/tests/bdd/load.feature_test.go
Normal file
417
backend/tests/bdd/load.feature_test.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// TestFeatureFileUpdatesUnderLoad covers:
|
||||
//
|
||||
// Given many documents and thousands of comments in flight
|
||||
// When they are written concurrently through the API
|
||||
// Then every update lands on disk with no torn or lost writes
|
||||
func TestFeatureFileUpdatesUnderLoad(t *testing.T) {
|
||||
Run(t, Feature{
|
||||
Name: "File updates under load",
|
||||
Scenarios: []Scenario{
|
||||
{
|
||||
Name: "concurrent comment injection loses no entry",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page with 400 concurrent feedback writers", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("hot-service", "# Hot Service\n\n## For Operators\n\nScaling notes.\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "400 comments are appended concurrently across all three roles", Do: func(c *Context) error {
|
||||
const writers = 400
|
||||
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers)
|
||||
start := make(chan struct{})
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
<-start // release every writer at once
|
||||
role := roles[index%len(roles)]
|
||||
if _, err := appendComment(c, "hot-service", role,
|
||||
fmt.Sprintf("user-%03d", index),
|
||||
fmt.Sprintf("finding number %d", index),
|
||||
); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "all 400 comments are present in the JSON array", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 400 {
|
||||
return errf("expected 400 persisted comments, got %d", len(comments))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "no comment was lost to a read-modify-write race", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(comments))
|
||||
for _, comment := range comments {
|
||||
key := fmt.Sprintf("%s|%s", comment.Author, comment.Content)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return errf("duplicate entry survived: %s", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
if len(seen) != 400 {
|
||||
return errf("expected 400 distinct entries, got %d", len(seen))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "every comment carries the badge for its own role", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, comment := range comments {
|
||||
if comment.Badge != badgeContract[comment.Role] {
|
||||
return errf("comment %s has badge %q, want %q",
|
||||
comment.ID, comment.Badge, badgeContract[comment.Role])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the file on disk is a single valid JSON array, never a torn write", Do: func(c *Context) error {
|
||||
raw, err := c.RawJSON("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") ||
|
||||
!strings.HasSuffix(strings.TrimSpace(string(raw)), "]") {
|
||||
return errf("feedback file is not a well-formed JSON array")
|
||||
}
|
||||
var decoded []storage.Comment
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return errf("feedback file failed to decode: %w", err)
|
||||
}
|
||||
return ExpectEqual(len(decoded), 400, "decoded comment count")
|
||||
}},
|
||||
{Text: "the HTTP read path agrees with the file on disk", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/hot-service/comments", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
listed, err := Decode[struct {
|
||||
Count int `json:"count"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(listed.Count, 400, "API comment count")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "readers never observe a partially written document while writers stream updates",
|
||||
Given: Steps{
|
||||
{Text: "a document with a long body", Do: func(c *Context) error {
|
||||
body := strings.Repeat("Sentinel paragraph for torn-read detection.\n\n", 400)
|
||||
_, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body)
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "20 writers and 40 readers hammer the API concurrently", Do: func(c *Context) error {
|
||||
const (
|
||||
writers = 20
|
||||
readers = 40
|
||||
rounds = 25
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers+readers)
|
||||
|
||||
for w := 0; w < writers; w++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
for round := 0; round < rounds; round++ {
|
||||
body := strings.Repeat(
|
||||
fmt.Sprintf("writer %d round %d payload line.\n\n", index, round), 200)
|
||||
if _, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
for r := 0; r < readers; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for round := 0; round < rounds*4; round++ {
|
||||
content, err := c.Engine.ReadDoc("live-doc")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(content, "# Live Doc\n\n") {
|
||||
errs <- errf("reader saw a truncated document header: %q", truncate(content))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "every read returned a complete document", Do: func(c *Context) error {
|
||||
content, err := c.RawDoc("live-doc")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(content, "# Live Doc\n\n") {
|
||||
return errf("final document is malformed")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "no temporary file was left behind in data/docs", Do: func(c *Context) error {
|
||||
entries, err := listDir(c.DataDir + "/docs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range entries {
|
||||
if strings.HasPrefix(name, ".godoc-tmp-") {
|
||||
return errf("atomic-write temp file leaked: %s", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "many documents can be seeded in parallel without cross-contamination",
|
||||
Given: Steps{
|
||||
{Text: "120 documents seeded across 8 workers with 25 comments each", Do: func(c *Context) error {
|
||||
const (
|
||||
docs = 120
|
||||
perDoc = 25
|
||||
numWorkers = 8
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, numWorkers)
|
||||
jobs := make(chan int, docs)
|
||||
|
||||
for i := 0; i < docs; i++ {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
for w := 0; w < numWorkers; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||
for index := range jobs {
|
||||
title := fmt.Sprintf("Service Suite %03d Overview", index)
|
||||
slug := storage.Slugify(title)
|
||||
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n",
|
||||
title)
|
||||
if _, err := c.Engine.SaveDoc(title, body); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
for n := 0; n < perDoc; n++ {
|
||||
role := roles[n%len(roles)]
|
||||
if _, err := c.Engine.AppendComment(slug, storage.CommentInput{
|
||||
Author: fmt.Sprintf("w%d-u%d", worker, n),
|
||||
Role: string(role),
|
||||
Content: fmt.Sprintf("note %d for %s", n, slug),
|
||||
}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the document index is read back", Do: func(c *Context) error {
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "all 120 slugified documents are indexed", Do: func(c *Context) error {
|
||||
metas, err := c.Engine.ListDocs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(len(metas), 120, "indexed document count")
|
||||
}},
|
||||
{Text: "each document owns exactly its own 25 comments", Do: func(c *Context) error {
|
||||
for index := 0; index < 120; index++ {
|
||||
slug := storage.Slugify(fmt.Sprintf("Service Suite %03d Overview", index))
|
||||
comments, err := c.RawComments(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 25 {
|
||||
return errf("%s: expected 25 comments, got %d", slug, len(comments))
|
||||
}
|
||||
for _, comment := range comments {
|
||||
if !strings.Contains(comment.Content, slug) {
|
||||
return errf("%s: comment from another document leaked in: %q", slug, comment.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the aggregate stats total 3000 comments", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/stats", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := Decode[struct {
|
||||
Docs int `json:"docs"`
|
||||
Comments int `json:"comments"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stats.Docs != 120 {
|
||||
return errf("expected 120 docs, got %d", stats.Docs)
|
||||
}
|
||||
return ExpectEqual(stats.Comments, 3000, "aggregate comment count")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "a burst of HTTP comment posts is durable",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page served over HTTP", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("POST", "/api/docs/http-burst", "text/markdown", "# HTTP Burst\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 201)
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "200 concurrent POSTs hit /api/docs/http-burst/comments", Do: func(c *Context) error {
|
||||
const posts = 200
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, posts)
|
||||
|
||||
for i := 0; i < posts; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
role := []string{"user", "operator", "developer"}[index%3]
|
||||
body := fmt.Sprintf(`{"author":"burst-%03d","role":"%s","content":"burst comment %d"}`, index, role, index)
|
||||
response, payload, err := c.Do("POST", "/api/docs/http-burst/comments", "application/json", body)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if response.StatusCode != 201 {
|
||||
errs <- errf("POST %d returned %d: %s", index, response.StatusCode, payload)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the feedback file holds all 200 posts", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("http-burst")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 200 {
|
||||
return errf("expected 200 persisted comments, got %d", len(comments))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "every comment has a unique ID and a role badge", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("http-burst")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make(map[string]struct{}, len(comments))
|
||||
for _, comment := range comments {
|
||||
if _, duplicate := ids[comment.ID]; duplicate {
|
||||
return errf("duplicate comment ID %s", comment.ID)
|
||||
}
|
||||
ids[comment.ID] = struct{}{}
|
||||
if comment.Badge == "" {
|
||||
return errf("comment %s has no badge", comment.ID)
|
||||
}
|
||||
if comment.Timestamp.IsZero() {
|
||||
return errf("comment %s has no timestamp", comment.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the write completes well inside a test timeout", Do: func(c *Context) error {
|
||||
// Guards against a regression to unbounded fsync storms: the whole
|
||||
// scenario must finish inside the 10 minute suite budget.
|
||||
if elapsed := time.Since(c.StartedAt); elapsed > 2*time.Minute {
|
||||
return errf("burst scenario took %s", elapsed)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
12
backend/tests/bdd/logger.go
Normal file
12
backend/tests/bdd/logger.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// quietLogger keeps the BDD output readable: the harness reports failures itself,
|
||||
// so the router's per-request access log is discarded.
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
220
backend/tests/docgen_import_test.go
Normal file
220
backend/tests/docgen_import_test.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// This file holds the end-to-end suite for the documentation importer: analyse a
|
||||
// real project tree on disk, render a page, merge it through the storage engine,
|
||||
// and read it back over HTTP. No mocks anywhere -- if the pipeline breaks, these
|
||||
// tests break.
|
||||
package tests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/docgen"
|
||||
)
|
||||
|
||||
// getJSON fetches a resource as JSON, which is how the frontend reads pages.
|
||||
// The API negotiates with ?format=json rather than the Accept header.
|
||||
func (h *harness) getJSON(path string) map[string]any {
|
||||
h.t.Helper()
|
||||
if !strings.Contains(path, "?") {
|
||||
path += "?format=json"
|
||||
}
|
||||
payload := h.expectStatus(http.MethodGet, path, "", "", http.StatusOK)
|
||||
return decodeJSON[map[string]any](h.t, payload)
|
||||
}
|
||||
|
||||
func TestIntegrationDocgenImport(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
workspace := t.TempDir()
|
||||
writeFixtureProject(t, filepath.Join(workspace, "go-fixture"))
|
||||
|
||||
project, err := docgen.Analyze("go-fixture", filepath.Join(workspace, "go-fixture"))
|
||||
if err != nil {
|
||||
t.Fatalf("integration: analyse fixture: %v", err)
|
||||
}
|
||||
page, err := docgen.Render(project)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: render fixture: %v", err)
|
||||
}
|
||||
|
||||
slug, err := h.engine.SaveDoc(project.Name, page)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: import page: %v", err)
|
||||
}
|
||||
if slug != "go-fixture" {
|
||||
t.Fatalf("integration: expected slug go-fixture, got %q", slug)
|
||||
}
|
||||
|
||||
// The page lands on disk exactly once, wrapped in generated markers that
|
||||
// record which project it came from.
|
||||
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "go-fixture.md")))
|
||||
if !strings.HasPrefix(onDisk, "<!-- go-doc:generated:begin") {
|
||||
t.Errorf("imported page is missing its opening marker:\n%s", firstLineOf(onDisk))
|
||||
}
|
||||
if !strings.Contains(onDisk, "source=go-fixture") {
|
||||
t.Errorf("imported page does not record its source project:\n%s", firstLineOf(onDisk))
|
||||
}
|
||||
if strings.Count(onDisk, "go-doc:generated:end") != 1 {
|
||||
t.Errorf("imported page has %d closing markers, want 1",
|
||||
strings.Count(onDisk, "go-doc:generated:end"))
|
||||
}
|
||||
|
||||
// The API serves it under the title the index shows.
|
||||
doc := h.getJSON("/api/docs/go-fixture")
|
||||
if doc["title"] != "go-fixture" {
|
||||
t.Errorf("title = %v, want go-fixture", doc["title"])
|
||||
}
|
||||
content, _ := doc["content"].(string)
|
||||
if content != onDisk {
|
||||
t.Error("API content differs from the file on disk")
|
||||
}
|
||||
|
||||
// Every audience tab the reader keys off is present, in order.
|
||||
users := strings.Index(content, "## For Users")
|
||||
operators := strings.Index(content, "## For Operators")
|
||||
developers := strings.Index(content, "## For Developers")
|
||||
if users < 0 || operators < 0 || developers < 0 {
|
||||
t.Fatalf("audience sections missing: %d %d %d", users, operators, developers)
|
||||
}
|
||||
if !(users < operators && operators < developers) {
|
||||
t.Errorf("audience sections are out of order: %d %d %d", users, operators, developers)
|
||||
}
|
||||
|
||||
// Facts extracted from the source reached the page.
|
||||
for _, want := range []string{"| `GET` | `/healthz` | `health` |", "`DEMO_HTTP_PORT`", "`make build`"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("imported page is missing extracted fact %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// It appears in the index.
|
||||
index := h.getJSON("/api/docs")
|
||||
docs, _ := index["docs"].([]any)
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("expected 1 indexed document, got %d", len(docs))
|
||||
}
|
||||
entry, _ := docs[0].(map[string]any)
|
||||
if entry["slug"] != "go-fixture" {
|
||||
t.Errorf("index slug = %v, want go-fixture", entry["slug"])
|
||||
}
|
||||
|
||||
// A comment attaches to a generated page like any other document.
|
||||
h.expectStatus(http.MethodPost, "/api/docs/go-fixture/comments",
|
||||
"application/json",
|
||||
`{"role":"operator","author":"sre","content":"The port table needs the compose port too."}`,
|
||||
http.StatusCreated)
|
||||
stats := h.getJSON("/api/docs/go-fixture/stats")
|
||||
if stats["total"] != float64(1) {
|
||||
t.Errorf("comment count = %v, want 1", stats["total"])
|
||||
}
|
||||
|
||||
// Re-importing identical content changes nothing, and prose a human added
|
||||
// outside the markers survives the sync.
|
||||
pagePath := filepath.Join(h.docsDir, "go-fixture.md")
|
||||
withFooter := onDisk + "\n## Notes from the team\n\nCheck the runbook before deploying.\n"
|
||||
if err := os.WriteFile(pagePath, []byte(withFooter), 0o644); err != nil {
|
||||
t.Fatalf("integration: write page: %v", err)
|
||||
}
|
||||
|
||||
merged, status, err := docgen.Merge(withFooter, page, false)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: re-merge: %v", err)
|
||||
}
|
||||
if status != docgen.StatusUnchanged {
|
||||
t.Errorf("re-importing an unchanged project reported %q, want unchanged", status)
|
||||
}
|
||||
if _, err := h.engine.SaveDoc(project.Name, merged); err != nil {
|
||||
t.Fatalf("integration: re-import: %v", err)
|
||||
}
|
||||
if reread := string(requireFile(t, pagePath)); !strings.Contains(reread, "Check the runbook before deploying.") {
|
||||
t.Error("re-import dropped hand-written prose")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationDocgenProtectsHandWrittenPages proves the importer never
|
||||
// overwrites a document that it did not generate.
|
||||
func TestIntegrationDocgenProtectsHandWrittenPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
const handWritten = "# go-doc\n\nWritten by a person, with no generated markers.\n"
|
||||
if _, err := h.engine.SaveDoc("go-doc", handWritten); err != nil {
|
||||
t.Fatalf("integration: seed hand-written page: %v", err)
|
||||
}
|
||||
|
||||
generated, err := docgen.Render(&docgen.Project{Name: "go-doc", Summary: "Machine written."})
|
||||
if err != nil {
|
||||
t.Fatalf("integration: render: %v", err)
|
||||
}
|
||||
|
||||
pagePath := filepath.Join(h.docsDir, "go-doc.md")
|
||||
existing := string(requireFile(t, pagePath))
|
||||
|
||||
merged, status, err := docgen.Merge(existing, generated, false)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: merge: %v", err)
|
||||
}
|
||||
if status != docgen.StatusSkipped {
|
||||
t.Errorf("merge status = %q, want skipped for an unmarked page", status)
|
||||
}
|
||||
if merged != existing {
|
||||
t.Error("a hand-written page was modified by the importer")
|
||||
}
|
||||
if _, err := h.engine.SaveDoc("go-doc", merged); err != nil {
|
||||
t.Fatalf("integration: save: %v", err)
|
||||
}
|
||||
if reread := string(requireFile(t, pagePath)); reread != handWritten {
|
||||
t.Errorf("hand-written page changed on disk:\n%s", reread)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationDocgenMissingProjectIsReportedNotIgnored covers the case the
|
||||
// importer must never paper over: a project directory that cannot be analysed.
|
||||
func TestIntegrationDocgenMissingProjectIsReportedNotIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
if _, err := docgen.Analyze("go-absent", filepath.Join(workspace, "go-absent")); err == nil {
|
||||
t.Fatal("analysing a missing project should fail loudly")
|
||||
}
|
||||
}
|
||||
|
||||
// writeFixtureProject lays out a small project exercising the extractors the
|
||||
// importer depends on: routes, flags, environment, a module, a Makefile and a
|
||||
// README.
|
||||
func writeFixtureProject(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
|
||||
files := map[string]string{
|
||||
"README.md": "# Demo Service\n\nDemo answers questions and exposes a JSON API.\n",
|
||||
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire github.com/go-chi/chi/v5 v5.2.3\n",
|
||||
"Makefile": "build:\n\tgo build ./...\n",
|
||||
".env.example": "DEMO_HTTP_PORT=8080\n",
|
||||
"internal/server/routes.go": "package server\n\n" +
|
||||
"import \"net/http\"\n\n" +
|
||||
"func Routes(mux *http.ServeMux) {\n" +
|
||||
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
|
||||
"\tmux.HandleFunc(\"/api/items\", listItems)\n}\n",
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
path := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("integration: mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("integration: write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstLineOf(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
842
backend/tests/integration_test.go
Normal file
842
backend/tests/integration_test.go
Normal file
|
|
@ -0,0 +1,842 @@
|
|||
// Package tests contains the end-to-end API suite.
|
||||
//
|
||||
// These are true integration tests: every request travels over HTTP to a live
|
||||
// server, every assertion inspects the real filesystem. There are no mocks, no
|
||||
// fakes and no in-memory doubles anywhere in this file -- if the flat-file engine
|
||||
// breaks, these tests break.
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-doc/router"
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// harness wires a real storage engine to a real HTTP server over a temporary
|
||||
// data directory.
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
dataDir string
|
||||
engine *storage.Engine
|
||||
server *httptest.Server
|
||||
client *http.Client
|
||||
docsDir string
|
||||
notePath string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
engine, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: provision engine: %v", err)
|
||||
}
|
||||
|
||||
handler, err := router.NewWithConfig(router.Config{
|
||||
Engine: engine,
|
||||
Logger: discardLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("integration: build router: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return &harness{
|
||||
t: t,
|
||||
dataDir: dataDir,
|
||||
engine: engine,
|
||||
server: server,
|
||||
client: server.Client(),
|
||||
docsDir: filepath.Join(dataDir, "docs"),
|
||||
notePath: filepath.Join(dataDir, "comments"),
|
||||
}
|
||||
}
|
||||
|
||||
// request performs an HTTP call and returns the response with its body.
|
||||
func (h *harness) request(method, path, contentType, body string) (*http.Response, []byte) {
|
||||
h.t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, h.server.URL+path, reader)
|
||||
if err != nil {
|
||||
h.t.Fatalf("integration: build %s %s: %v", method, path, err)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatalf("integration: %s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
h.t.Fatalf("integration: read body of %s %s: %v", method, path, err)
|
||||
}
|
||||
return resp, payload
|
||||
}
|
||||
|
||||
func (h *harness) expectStatus(method, path, contentType, body string, want int) []byte {
|
||||
h.t.Helper()
|
||||
resp, payload := h.request(method, path, contentType, body)
|
||||
if resp.StatusCode != want {
|
||||
h.t.Fatalf("%s %s: expected HTTP %d, got %d (body: %s)", method, path, want, resp.StatusCode, payload)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func decodeJSON[T any](t *testing.T, payload []byte) T {
|
||||
t.Helper()
|
||||
var out T
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
t.Fatalf("integration: decode %s: %v", payload, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// requireFile asserts a file exists on the real filesystem and returns its bytes.
|
||||
func requireFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: expected %s to exist: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func requireNoFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Fatalf("integration: expected %s to be absent", path)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("integration: stat %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationDocumentLifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationDocumentLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
t.Run("health reports the live data root", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/health", "", "", http.StatusOK)
|
||||
health := decodeJSON[struct {
|
||||
Status string `json:"status"`
|
||||
DataRoot string `json:"data_root"`
|
||||
}](t, payload)
|
||||
|
||||
if health.Status != "ok" {
|
||||
t.Fatalf("expected status ok, got %q", health.Status)
|
||||
}
|
||||
if health.DataRoot != h.dataDir {
|
||||
t.Fatalf("expected data root %q, got %q", h.dataDir, health.DataRoot)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST creates a slugified Markdown file on disk", func(t *testing.T) {
|
||||
payload := h.expectStatus(
|
||||
"POST", "/api/docs/Core%20Service%20API", "text/markdown",
|
||||
"# Core Service API\n\nBilling and invoicing endpoints.\n",
|
||||
http.StatusCreated,
|
||||
)
|
||||
saved := decodeJSON[struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Created bool `json:"created"`
|
||||
}](t, payload)
|
||||
|
||||
if saved.Slug != "core-service-api" {
|
||||
t.Fatalf("expected slug core-service-api, got %q", saved.Slug)
|
||||
}
|
||||
if !saved.Created {
|
||||
t.Fatal("expected created=true on first write")
|
||||
}
|
||||
if saved.Title != "Core Service API" {
|
||||
t.Fatalf("expected the H1 to become the title, got %q", saved.Title)
|
||||
}
|
||||
|
||||
onDisk := requireFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
|
||||
if !bytes.Contains(onDisk, []byte("Billing and invoicing endpoints.")) {
|
||||
t.Fatalf("file content mismatch: %s", onDisk)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET returns the raw Markdown with the right content type", func(t *testing.T) {
|
||||
resp, payload := h.request("GET", "/api/docs/core-service-api", "", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
|
||||
t.Fatalf("expected text/markdown, got %q", ct)
|
||||
}
|
||||
if !strings.HasPrefix(string(payload), "# Core Service API") {
|
||||
t.Fatalf("unexpected markdown body: %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET ?format=json returns the document envelope", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/core-service-api?format=json", "", "", http.StatusOK)
|
||||
doc := decodeJSON[struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Size int64 `json:"size"`
|
||||
}](t, payload)
|
||||
|
||||
if doc.Slug != "core-service-api" || doc.Title != "Core Service API" {
|
||||
t.Fatalf("unexpected envelope: %+v", doc)
|
||||
}
|
||||
if doc.Size <= 0 {
|
||||
t.Fatalf("expected a positive size, got %d", doc.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST with a JSON body updates the file in place", func(t *testing.T) {
|
||||
h.expectStatus(
|
||||
"POST", "/api/docs/core-service-api", "application/json",
|
||||
`{"title":"Core Service API v2","content":"# Core Service API v2\n\nRewritten.\n"}`,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "core-service-api.md")))
|
||||
if strings.Contains(onDisk, "Billing and invoicing") {
|
||||
t.Fatalf("update did not replace the content wholesale: %s", onDisk)
|
||||
}
|
||||
if !strings.Contains(onDisk, "# Core Service API v2") {
|
||||
t.Fatalf("update missing from disk: %s", onDisk)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /api/docs indexes the document", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs", "", "", http.StatusOK)
|
||||
index := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
Docs []storage.DocMeta `json:"docs"`
|
||||
}](t, payload)
|
||||
|
||||
if index.Count != 1 || index.Docs[0].Slug != "core-service-api" {
|
||||
t.Fatalf("unexpected index: %+v", index)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search filters the index", func(t *testing.T) {
|
||||
h.expectStatus("POST", "/api/docs/Payments%20Gateway", "text/markdown", "# Payments Gateway\n", http.StatusCreated)
|
||||
|
||||
payload := h.expectStatus("GET", "/api/docs?q=payment", "", "", http.StatusOK)
|
||||
index := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
}](t, payload)
|
||||
if index.Count != 1 {
|
||||
t.Fatalf("expected exactly one search hit, got %d", index.Count)
|
||||
}
|
||||
|
||||
payload = h.expectStatus("GET", "/api/docs?q=nothing-matches", "", "", http.StatusOK)
|
||||
index = decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
}](t, payload)
|
||||
if index.Count != 0 {
|
||||
t.Fatalf("expected no hits, got %d", index.Count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE removes the file and cascades the feedback", func(t *testing.T) {
|
||||
h.expectStatus("POST", "/api/docs/core-service-api/comments", "application/json",
|
||||
`{"author":"ana","role":"developer","content":"delete me"}`, http.StatusCreated)
|
||||
requireFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
|
||||
|
||||
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNoContent)
|
||||
requireNoFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
|
||||
requireNoFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
|
||||
|
||||
h.expectStatus("GET", "/api/docs/core-service-api", "", "", http.StatusNotFound)
|
||||
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationCommentTracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationCommentTracking(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
h.expectStatus("POST", "/api/docs/Deploy%20Pipeline", "text/markdown", "# Deploy Pipeline\n", http.StatusCreated)
|
||||
|
||||
type created struct {
|
||||
Comment storage.Comment `json:"comment"`
|
||||
Badge string `json:"badge"`
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
role string
|
||||
author string
|
||||
content string
|
||||
badge string
|
||||
}{
|
||||
{"user", "sam", "The wizard confused me", "🟢"},
|
||||
{"Operator", "robin", "Rollback takes 20 minutes", "🔵"},
|
||||
{"DEVELOPER", "kit", "Add a --dry-run flag", "🔴"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run("role "+tc.role+" receives badge "+tc.badge, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"author":%q,"role":%q,"content":%q}`, tc.author, tc.role, tc.content)
|
||||
payload := h.expectStatus("POST", "/api/docs/deploy-pipeline/comments", "application/json", body, http.StatusCreated)
|
||||
result := decodeJSON[created](t, payload)
|
||||
|
||||
if result.Badge != tc.badge {
|
||||
t.Fatalf("expected badge %q, got %q", tc.badge, result.Badge)
|
||||
}
|
||||
if result.Comment.Badge != tc.badge {
|
||||
t.Fatalf("expected the stored badge %q, got %q", tc.badge, result.Comment.Badge)
|
||||
}
|
||||
if result.Comment.ID == "" {
|
||||
t.Fatal("expected a generated comment ID")
|
||||
}
|
||||
if result.Comment.Timestamp.IsZero() {
|
||||
t.Fatal("expected a timestamp")
|
||||
}
|
||||
if string(result.Comment.Role) != strings.ToLower(tc.role) {
|
||||
t.Fatalf("expected the canonical role, got %q", result.Comment.Role)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("the feedback file is a JSON array in insertion order", func(t *testing.T) {
|
||||
raw := requireFile(t, filepath.Join(h.notePath, "deploy-pipeline-comments.json"))
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
||||
t.Fatalf("expected a JSON array, got: %s", raw)
|
||||
}
|
||||
|
||||
var stored []storage.Comment
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatalf("feedback file is not valid JSON: %v", err)
|
||||
}
|
||||
if len(stored) != 3 {
|
||||
t.Fatalf("expected 3 comments, got %d", len(stored))
|
||||
}
|
||||
for i, want := range []struct{ role, badge string }{
|
||||
{"user", "🟢"},
|
||||
{"operator", "🔵"},
|
||||
{"developer", "🔴"},
|
||||
} {
|
||||
if string(stored[i].Role) != want.role || stored[i].Badge != want.badge {
|
||||
t.Fatalf("comment %d: got %s/%s, want %s/%s", i, stored[i].Role, stored[i].Badge, want.role, want.badge)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET lists the comments with their badges", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
|
||||
listed := decodeJSON[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
Count int `json:"count"`
|
||||
}](t, payload)
|
||||
|
||||
if listed.Count != 3 || len(listed.Comments) != 3 {
|
||||
t.Fatalf("expected 3 comments, got %d", listed.Count)
|
||||
}
|
||||
if listed.Comments[2].Badge != "🔴" {
|
||||
t.Fatalf("expected the developer badge, got %q", listed.Comments[2].Badge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE removes a single comment by ID", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
|
||||
listed := decodeJSON[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
}](t, payload)
|
||||
|
||||
target := listed.Comments[0].ID
|
||||
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNoContent)
|
||||
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNotFound)
|
||||
|
||||
remaining := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
}](t, h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK))
|
||||
if remaining.Count != 2 {
|
||||
t.Fatalf("expected 2 remaining comments, got %d", remaining.Count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stats aggregate by role", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/stats", "", "", http.StatusOK)
|
||||
stats := decodeJSON[storage.CommentStats](t, payload)
|
||||
|
||||
if stats.Total != 2 {
|
||||
t.Fatalf("expected total 2, got %d", stats.Total)
|
||||
}
|
||||
if stats.ByRole["operator"] != 1 || stats.ByRole["developer"] != 1 {
|
||||
t.Fatalf("unexpected role tally: %+v", stats.ByRole)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the role catalogue is exposed for the UI", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/roles", "", "", http.StatusOK)
|
||||
catalogue := decodeJSON[struct {
|
||||
Roles []struct {
|
||||
Role string `json:"role"`
|
||||
Badge string `json:"badge"`
|
||||
Label string `json:"label"`
|
||||
Hex string `json:"hex"`
|
||||
} `json:"roles"`
|
||||
}](t, payload)
|
||||
|
||||
want := map[string][3]string{
|
||||
"user": {"🟢", "User", "#22c55e"},
|
||||
"operator": {"🔵", "Operator", "#3b82f6"},
|
||||
"developer": {"🔴", "Developer", "#ef4444"},
|
||||
}
|
||||
if len(catalogue.Roles) != 3 {
|
||||
t.Fatalf("expected 3 roles, got %d", len(catalogue.Roles))
|
||||
}
|
||||
for _, role := range catalogue.Roles {
|
||||
expected, ok := want[role.Role]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected role %q", role.Role)
|
||||
}
|
||||
if role.Badge != expected[0] || role.Label != expected[1] || role.Hex != expected[2] {
|
||||
t.Fatalf("role %s mismatch: %+v", role.Role, role)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationValidationAndErrors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationValidationAndErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
t.Run("unknown document returns a structured 404", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/does-not-exist", "", "", http.StatusNotFound)
|
||||
body := decodeJSON[struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Status int `json:"status"`
|
||||
}](t, payload)
|
||||
|
||||
if body.Error.Code != "not_found" || body.Status != http.StatusNotFound {
|
||||
t.Fatalf("unexpected error body: %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid role returns 400 invalid_role", func(t *testing.T) {
|
||||
h.expectStatus("POST", "/api/docs/guarded", "text/markdown", "# Guarded\n", http.StatusCreated)
|
||||
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
|
||||
`{"author":"mallory","role":"root","content":"escalate"}`, http.StatusBadRequest)
|
||||
|
||||
if !strings.Contains(string(payload), "invalid_role") {
|
||||
t.Fatalf("expected invalid_role, got %s", payload)
|
||||
}
|
||||
requireNoFile(t, filepath.Join(h.notePath, "guarded-comments.json"))
|
||||
})
|
||||
|
||||
t.Run("empty comment content returns 400 invalid_comment", func(t *testing.T) {
|
||||
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
|
||||
`{"author":"ana","role":"user","content":" "}`, http.StatusBadRequest)
|
||||
if !strings.Contains(string(payload), "invalid_comment") {
|
||||
t.Fatalf("expected invalid_comment, got %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON returns 400 invalid_body", func(t *testing.T) {
|
||||
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json", `{"author":`, http.StatusBadRequest)
|
||||
if !strings.Contains(string(payload), "invalid_body") {
|
||||
t.Fatalf("expected invalid_body, got %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traversal slugs are refused", func(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/api/docs/..%2F..%2Fetc%2Fpasswd",
|
||||
"/api/docs/%2Fetc%2Fpasswd",
|
||||
} {
|
||||
h.expectStatus("POST", path, "text/markdown", "nope", http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized documents are refused", func(t *testing.T) {
|
||||
huge := strings.Repeat("a", int(router.DefaultMaxDocBytes)+1024)
|
||||
h.expectStatus("POST", "/api/docs/huge", "text/markdown", huge, http.StatusBadRequest)
|
||||
requireNoFile(t, filepath.Join(h.docsDir, "huge.md"))
|
||||
})
|
||||
|
||||
t.Run("unknown API routes return JSON 404, not an HTML page", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/nope", "", "", http.StatusNotFound)
|
||||
if !strings.Contains(string(payload), "not_found") {
|
||||
t.Fatalf("expected a JSON error, got %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationCORS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationCORS(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
t.Run("preflight is answered for the Vite dev server", func(t *testing.T) {
|
||||
resp, _ := h.request("OPTIONS", "/api/docs/core-service-api", "", "")
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
|
||||
t.Fatalf("expected a wildcard origin, got %q", origin)
|
||||
}
|
||||
methods := resp.Header.Get("Access-Control-Allow-Methods")
|
||||
for _, verb := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
|
||||
if !strings.Contains(methods, verb) {
|
||||
t.Fatalf("expected %s in %q", verb, methods)
|
||||
}
|
||||
}
|
||||
if headers := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(headers, "Content-Type") {
|
||||
t.Fatalf("expected Content-Type to be allowed, got %q", headers)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-origin reads carry the CORS header", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", h.server.URL+"/api/docs", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Origin", "http://127.0.0.1:5173")
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
|
||||
t.Fatalf("expected a CORS header, got %q", origin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an allow-list echoes only permitted origins", func(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
engine, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := router.NewWithConfig(router.Config{
|
||||
Engine: engine,
|
||||
AllowedOrigins: []string{"http://127.0.0.1:5173"},
|
||||
Logger: discardLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
for origin, wantEchoed := range map[string]bool{
|
||||
"http://127.0.0.1:5173": true,
|
||||
"http://evil.example.com": false,
|
||||
} {
|
||||
req, err := http.NewRequest("GET", server.URL+"/api/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Origin", origin)
|
||||
|
||||
resp, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
got := resp.Header.Get("Access-Control-Allow-Origin")
|
||||
if wantEchoed && got != origin {
|
||||
t.Fatalf("expected %q to be echoed, got %q", origin, got)
|
||||
}
|
||||
if !wantEchoed && got == origin {
|
||||
t.Fatalf("origin %q should not have been echoed", origin)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationConcurrentLoad
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationConcurrentLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
const (
|
||||
docs = 40
|
||||
perDoc = 30
|
||||
commentsTotal = docs * perDoc
|
||||
)
|
||||
|
||||
t.Run("concurrent writers all land on disk", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, docs)
|
||||
|
||||
for d := 0; d < docs; d++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
title := fmt.Sprintf("Load Service %02d", index)
|
||||
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n", title)
|
||||
|
||||
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title), "text/markdown", body)
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
errs <- fmt.Errorf("save %s returned %d: %s", title, resp.StatusCode, payload)
|
||||
return
|
||||
}
|
||||
|
||||
for n := 0; n < perDoc; n++ {
|
||||
role := []string{"user", "operator", "developer"}[n%3]
|
||||
comment := fmt.Sprintf(
|
||||
`{"author":"load-%02d","role":%q,"content":"comment %d"}`, index, role, n)
|
||||
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title)+"/comments", "application/json", comment)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
errs <- fmt.Errorf("comment on %s returned %d: %s", title, resp.StatusCode, payload)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(d)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the filesystem holds every document", func(t *testing.T) {
|
||||
entries, err := os.ReadDir(h.docsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != docs {
|
||||
t.Fatalf("expected %d Markdown files, got %d", docs, len(entries))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the filesystem holds every comment", func(t *testing.T) {
|
||||
entries, err := os.ReadDir(h.notePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != docs {
|
||||
t.Fatalf("expected %d feedback files, got %d", docs, len(entries))
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, entry := range entries {
|
||||
raw, err := os.ReadFile(filepath.Join(h.notePath, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored []storage.Comment
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatalf("%s is not a valid JSON array: %v", entry.Name(), err)
|
||||
}
|
||||
total += len(stored)
|
||||
}
|
||||
if total != commentsTotal {
|
||||
t.Fatalf("expected %d comments across all files, got %d", commentsTotal, total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the API agrees with the filesystem", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/stats", "", "", http.StatusOK)
|
||||
stats := decodeJSON[struct {
|
||||
Docs int `json:"docs"`
|
||||
Comments int `json:"comments"`
|
||||
}](t, payload)
|
||||
|
||||
if stats.Docs != docs {
|
||||
t.Fatalf("expected %d docs, got %d", docs, stats.Docs)
|
||||
}
|
||||
if stats.Comments != commentsTotal {
|
||||
t.Fatalf("expected %d comments, got %d", commentsTotal, stats.Comments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no temp files leaked and every comment is badged", func(t *testing.T) {
|
||||
entries, err := os.ReadDir(h.docsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".godoc-tmp-") {
|
||||
t.Fatalf("leaked atomic-write temp file: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
for d := 0; d < docs; d++ {
|
||||
slug := storage.Slugify(fmt.Sprintf("Load Service %02d", d))
|
||||
raw := requireFile(t, filepath.Join(h.notePath, slug+"-comments.json"))
|
||||
var stored []storage.Comment
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != perDoc {
|
||||
t.Fatalf("%s: expected %d comments, got %d", slug, perDoc, len(stored))
|
||||
}
|
||||
for _, comment := range stored {
|
||||
if comment.Badge != comment.Role.Badge() {
|
||||
t.Fatalf("%s: comment %s has badge %q for role %s", slug, comment.ID, comment.Badge, comment.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationPersistenceAcrossRestart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationPersistenceAcrossRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
|
||||
// First "process": write through the API.
|
||||
engine, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := router.NewWithConfig(router.Config{Engine: engine, Logger: discardLogger()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := httptest.NewServer(handler)
|
||||
|
||||
post := func(method, path, contentType, body string) []byte {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, first.URL+path, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := first.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
t.Fatalf("%s %s returned %d: %s", method, path, resp.StatusCode, payload)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
post("POST", "/api/docs/Persistent%20Store", "text/markdown", "# Persistent Store\n\nSurvives restarts.\n")
|
||||
post("POST", "/api/docs/persistent-store/comments", "application/json",
|
||||
`{"author":"ana","role":"developer","content":"still here after a restart"}`)
|
||||
first.Close()
|
||||
|
||||
// Second "process": a brand new engine over the same directory.
|
||||
restarted, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err = router.NewWithConfig(router.Config{Engine: restarted, Logger: discardLogger()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := httptest.NewServer(handler)
|
||||
defer second.Close()
|
||||
|
||||
t.Run("the document survives", func(t *testing.T) {
|
||||
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store?format=json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
doc := decodeJSON[struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}](t, mustReadAll(resp))
|
||||
|
||||
if doc.Title != "Persistent Store" {
|
||||
t.Fatalf("unexpected title %q", doc.Title)
|
||||
}
|
||||
if !strings.Contains(doc.Content, "Survives restarts.") {
|
||||
t.Fatalf("unexpected content %q", doc.Content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the feedback survives with its badge", func(t *testing.T) {
|
||||
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store/comments")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
listed := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
}](t, mustReadAll(resp))
|
||||
|
||||
if listed.Count != 1 {
|
||||
t.Fatalf("expected 1 comment, got %d", listed.Count)
|
||||
}
|
||||
if listed.Comments[0].Badge != "🔴" {
|
||||
t.Fatalf("expected the developer badge, got %q", listed.Comments[0].Badge)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustReadAll(resp *http.Response) []byte {
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestIntegrationTimingBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
start := time.Now()
|
||||
h.expectStatus("POST", "/api/docs/timing", "text/markdown", strings.Repeat("Line of content.\n\n", 500), http.StatusCreated)
|
||||
for i := 0; i < 100; i++ {
|
||||
h.expectStatus("POST", "/api/docs/timing/comments", "application/json",
|
||||
fmt.Sprintf(`{"author":"bench","role":"user","content":"note %d"}`, i), http.StatusCreated)
|
||||
}
|
||||
h.expectStatus("GET", "/api/docs/timing", "", "", http.StatusOK)
|
||||
|
||||
if elapsed := time.Since(start); elapsed > 30*time.Second {
|
||||
t.Fatalf("write path took %s, which is far too slow for a flat-file engine", elapsed)
|
||||
}
|
||||
}
|
||||
12
backend/tests/logger_test.go
Normal file
12
backend/tests/logger_test.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// discardLogger silences the router's request log so `go test -v` output stays
|
||||
// focused on assertions.
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue