944 lines
35 KiB
Go
944 lines
35 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
"RenewCMS/internal/domain/article"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSeoDataArticlePatch(t *testing.T) {
|
|
t.Run("All SEO fields are set at once", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.authors.seed(5, "Camille", "camille")
|
|
e.articles.credit(seoSpringID, 5)
|
|
body := `{"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.", "cover_image_id": 5, "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.", "social_image_id": 6, "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": ["cover_alt", "short"]}`
|
|
|
|
rec := e.patch(t, seoSpringID, body)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
decoded := decodeSeoObject(t, rec)
|
|
wantResponse := map[string]any{
|
|
"slug": "spring-2026", "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.",
|
|
"cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.",
|
|
"social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true,
|
|
"seo_dismissed": []any{"cover_alt", "short"}, "title": seoSpringTitle, "body": "Body of Spring release", "site_id": float64(siteMainID),
|
|
}
|
|
for key, want := range wantResponse {
|
|
if !reflect.DeepEqual(decoded[key], want) {
|
|
t.Errorf("%s = %v, expected %v", key, decoded[key], want)
|
|
}
|
|
}
|
|
if authors := decoded["authors"].([]any); len(authors) != 1 {
|
|
t.Errorf("authors = %v", authors)
|
|
}
|
|
stored := e.stored(seoSpringID)
|
|
if stored.Slug != "spring-2026" || stored.Excerpt != "Short teaser." || stored.SEOTitle != "Spring release notes" || stored.SEODescription != "Everything new this spring." ||
|
|
stored.CoverImageID == nil || *stored.CoverImageID != 5 || stored.CoverImageAlt != "A field of tulips" || stored.SocialTitle != "Spring is here" ||
|
|
stored.SocialDescription != "Read the notes." || stored.SocialImageID == nil || *stored.SocialImageID != 6 || stored.CanonicalURL != "https://www.example.com/spring" ||
|
|
!stored.Noindex || !reflect.DeepEqual(stored.SEODismissed, []string{"cover_alt", "short"}) {
|
|
t.Errorf("stored = %+v", stored)
|
|
}
|
|
})
|
|
|
|
t.Run("Absent fields are untouched", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoSpringID)
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"seo_title": "New title"}`)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
want := before
|
|
want.SEOTitle = "New title"
|
|
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, want) {
|
|
t.Errorf("after = %+v, expected %+v", after, want)
|
|
}
|
|
})
|
|
|
|
t.Run("Text fields and the flag are cleared with empty values", func(t *testing.T) {
|
|
cases := []struct {
|
|
body string
|
|
check func(a article.Article) bool
|
|
}{
|
|
{`{"excerpt": ""}`, func(a article.Article) bool { return a.Excerpt == "" }},
|
|
{`{"seo_title": ""}`, func(a article.Article) bool { return a.SEOTitle == "" }},
|
|
{`{"seo_description": ""}`, func(a article.Article) bool { return a.SEODescription == "" }},
|
|
{`{"cover_image_alt": ""}`, func(a article.Article) bool { return a.CoverImageAlt == "" }},
|
|
{`{"social_title": ""}`, func(a article.Article) bool { return a.SocialTitle == "" }},
|
|
{`{"social_description": ""}`, func(a article.Article) bool { return a.SocialDescription == "" }},
|
|
{`{"canonical_url": ""}`, func(a article.Article) bool { return a.CanonicalURL == "" }},
|
|
{`{"noindex": false}`, func(a article.Article) bool { return !a.Noindex }},
|
|
{`{"seo_dismissed": []}`, func(a article.Article) bool { return len(a.SEODismissed) == 0 }},
|
|
{`{"cover_image_id": null}`, func(a article.Article) bool { return a.CoverImageID == nil }},
|
|
{`{"social_image_id": null}`, func(a article.Article) bool { return a.SocialImageID == nil }},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.body, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, c.body)
|
|
|
|
if rec.Code != http.StatusOK || !c.check(e.stored(seoSpringID)) {
|
|
t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID))
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Boundaries are accepted", func(t *testing.T) {
|
|
cases := []struct{ name, body string }{
|
|
{"excerpt 300 characters", `{"excerpt": "` + repeated("a", 300) + `"}`},
|
|
{"excerpt 300 multi-byte characters", `{"excerpt": "` + repeated("é", 300) + `"}`},
|
|
{"seo_title 120 characters", `{"seo_title": "` + repeated("a", 120) + `"}`},
|
|
{"seo_description 320 characters", `{"seo_description": "` + repeated("a", 320) + `"}`},
|
|
{"cover_image_alt 200 characters", `{"cover_image_alt": "` + repeated("a", 200) + `"}`},
|
|
{"social_title 120 characters", `{"social_title": "` + repeated("a", 120) + `"}`},
|
|
{"social_description 320 characters", `{"social_description": "` + repeated("a", 320) + `"}`},
|
|
{"slug 100 characters", `{"slug": "` + repeated("a", 100) + `"}`},
|
|
{"canonical_url with query and fragment", `{"canonical_url": "https://example.com/a?b=1#c"}`},
|
|
{"canonical_url 2048 characters", `{"canonical_url": "https://example.com/` + repeated("a", 2048-len("https://example.com/")) + `"}`},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
rec := e.patch(t, seoSpringID, c.body)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status %d: %.200s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Fields are validated", func(t *testing.T) {
|
|
const validator = "validator"
|
|
longURL := `{"canonical_url": "https://example.com/` + repeated("a", 2049-len("https://example.com/")) + `"}`
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
status int
|
|
text string
|
|
}{
|
|
{"excerpt 301", `{"excerpt": "` + repeated("a", 301) + `"}`, 400, validator},
|
|
{"seo_title 121", `{"seo_title": "` + repeated("a", 121) + `"}`, 400, validator},
|
|
{"seo_description 321", `{"seo_description": "` + repeated("a", 321) + `"}`, 400, validator},
|
|
{"cover_image_alt 201", `{"cover_image_alt": "` + repeated("a", 201) + `"}`, 400, validator},
|
|
{"social_title 121", `{"social_title": "` + repeated("a", 121) + `"}`, 400, validator},
|
|
{"social_description 321", `{"social_description": "` + repeated("a", 321) + `"}`, 400, validator},
|
|
{"canonical_url 2049", longURL, 400, validator},
|
|
{"slug uppercase", `{"slug": "Spring"}`, 400, "invalid_slug"},
|
|
{"slug underscore", `{"slug": "spring_2026"}`, 400, "invalid_slug"},
|
|
{"slug leading hyphen", `{"slug": "-spring"}`, 400, "invalid_slug"},
|
|
{"slug double hyphen", `{"slug": "spring--2026"}`, 400, "invalid_slug"},
|
|
{"slug 101", `{"slug": "` + repeated("a", 101) + `"}`, 400, "invalid_slug"},
|
|
{"canonical http", `{"canonical_url": "http://example.com/a"}`, 422, "invalid_url"},
|
|
{"canonical relative", `{"canonical_url": "/relative"}`, 422, "invalid_url"},
|
|
{"canonical bare host", `{"canonical_url": "example.com"}`, 422, "invalid_url"},
|
|
{"canonical no host", `{"canonical_url": "https://"}`, 422, "invalid_url"},
|
|
{"unknown dismissed code", `{"seo_dismissed": ["nope"]}`, 400, validator},
|
|
{"unknown cover image", `{"cover_image_id": 999}`, 400, "image_not_found"},
|
|
{"unknown social image", `{"social_image_id": 999}`, 400, "image_not_found"},
|
|
{"cover image zero", `{"cover_image_id": 0}`, 400, validator},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoSpringID)
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, c.body)
|
|
|
|
if c.text == validator {
|
|
expectStatusAndValidatorText(t, rec, c.status)
|
|
} else {
|
|
expectStatusAndText(t, rec, c.status, c.text)
|
|
}
|
|
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
|
|
t.Errorf("the article changed: %+v", after)
|
|
}
|
|
e.noEvent(t)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A wrong type or null on other fields is refused", func(t *testing.T) {
|
|
for _, body := range []string{`{}`, `{"colour": "x"}`, `{"slug": null}`, `{"excerpt": null}`, `{"noindex": null}`, `{"seo_dismissed": null}`, `{"noindex": "yes"}`, `{"excerpt": 5}`, `{"cover_image_id": "a"}`, `{"seo_dismissed": "x"}`} {
|
|
t.Run(body, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoSpringID)
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, body)
|
|
|
|
expectStatusAndText(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
|
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
|
|
t.Errorf("the article changed: %+v", after)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Duplicate dismissed codes are collapsed", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["short", "cover_alt", "short"]}`)
|
|
|
|
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, []string{"short", "cover_alt"}) {
|
|
t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed)
|
|
}
|
|
})
|
|
|
|
t.Run("Every known dismissed code is accepted", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
codes := article.DismissCodes()
|
|
|
|
rec := e.patch(t, seoSpringID, `{"seo_dismissed": ["`+strings.Join(codes, `", "`)+`"]}`)
|
|
|
|
if len(codes) != 11 || rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID).SEODismissed, codes) {
|
|
t.Errorf("status %d, dismissed %v", rec.Code, e.stored(seoSpringID).SEODismissed)
|
|
}
|
|
})
|
|
|
|
t.Run("A refused update changes nothing else", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"body": "New", "excerpt": "ok", "canonical_url": "http://x.example"}`)
|
|
|
|
expectStatusAndText(t, rec, http.StatusUnprocessableEntity, "invalid_url")
|
|
if after := e.stored(seoSpringID); !reflect.DeepEqual(after, before) {
|
|
t.Errorf("the article changed: %+v", after)
|
|
}
|
|
})
|
|
|
|
t.Run("SEO fields and the other fields change together", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.authors.seed(5, "Camille", "camille")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"body": "New", "site_id": 2, "author_ids": [5], "excerpt": "Teaser"}`)
|
|
|
|
stored := e.stored(seoSpringID)
|
|
if rec.Code != http.StatusOK || stored.Body != "New" || stored.SiteID != 2 || stored.Excerpt != "Teaser" || !reflect.DeepEqual(e.articles.credits[seoSpringID], []uint32{5}) {
|
|
t.Errorf("status %d, stored %+v, credits %v", rec.Code, stored, e.articles.credits[seoSpringID])
|
|
}
|
|
})
|
|
|
|
t.Run("An update without SEO fields keeps them", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoSpringID)
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"body": "New"}`)
|
|
|
|
want := before
|
|
want.Body = "New"
|
|
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.stored(seoSpringID), want) {
|
|
t.Errorf("status %d, stored %+v", rec.Code, e.stored(seoSpringID))
|
|
}
|
|
})
|
|
|
|
t.Run("Any account that can edit the article edits its SEO fields", func(t *testing.T) {
|
|
callers := []struct {
|
|
name string
|
|
send func(e *seoEnv, t *testing.T) int
|
|
}{
|
|
{"the owner's jwt cookie", func(e *seoEnv, t *testing.T) int {
|
|
return e.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
|
|
}},
|
|
{"a verified admin's jwt cookie", func(e *seoEnv, t *testing.T) int {
|
|
return e.as(t, apiKeyDevId, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
|
|
}},
|
|
{"a verified account with another role", func(e *seoEnv, t *testing.T) int {
|
|
return e.as(t, seoEditorID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
|
|
}},
|
|
{"a valid API key", func(e *seoEnv, t *testing.T) int {
|
|
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
|
|
return e.asKey(t, raw, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`).Code
|
|
}},
|
|
}
|
|
for _, c := range callers {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
status := c.send(e, t)
|
|
|
|
if status != http.StatusOK || e.stored(seoSpringID).Excerpt != "Teaser" {
|
|
t.Errorf("status %d, excerpt %q", status, e.stored(seoSpringID).Excerpt)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Without a verified account the article is not edited", func(t *testing.T) {
|
|
callers := []struct {
|
|
name string
|
|
userID uint32
|
|
status int
|
|
}{
|
|
{"an unverified account's jwt cookie", apiKeyQuinnId, http.StatusForbidden},
|
|
{"no credentials", 0, http.StatusUnauthorized},
|
|
}
|
|
for _, c := range callers {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.as(t, c.userID, http.MethodPatch, "/v1/article/8", `{"excerpt": "Teaser"}`)
|
|
|
|
if rec.Code != c.status || !reflect.DeepEqual(e.stored(seoSpringID), before) {
|
|
t.Errorf("status %d", rec.Code)
|
|
}
|
|
e.noEvent(t)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A missing article is not found", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
|
|
rec := e.patch(t, 999, `{"excerpt": "x"}`)
|
|
|
|
expectStatusAndText(t, rec, http.StatusNotFound, "Article not found")
|
|
e.noEvent(t)
|
|
})
|
|
|
|
t.Run("A malformed id is refused", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/article/abc", `{"excerpt": "x"}`)
|
|
|
|
expectStatusAndText(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
|
e.noEvent(t)
|
|
})
|
|
|
|
t.Run("noindex is only a flag", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
stored := e.seedSpring()
|
|
stored.IsOnline = true
|
|
e.articles.articles[seoSpringID] = stored
|
|
|
|
rec := e.patch(t, seoSpringID, `{"noindex": true}`)
|
|
anonymous := e.as(t, 0, http.MethodGet, "/v1/article/8", "")
|
|
|
|
if rec.Code != http.StatusOK || !e.stored(seoSpringID).IsOnline {
|
|
t.Fatalf("status %d, online %v", rec.Code, e.stored(seoSpringID).IsOnline)
|
|
}
|
|
if anonymous.Code != http.StatusOK || decodeSeoObject(t, anonymous)["noindex"] != true {
|
|
t.Errorf("anonymous read: %d %s", anonymous.Code, anonymous.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("The cover alt is independent of the cover", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
rec := e.patch(t, seoSpringID, `{"cover_image_alt": "A field"}`)
|
|
|
|
decoded := decodeSeoObject(t, rec)
|
|
if rec.Code != http.StatusOK || decoded["cover_image_id"] != nil || decoded["cover_image_alt"] != "A field" {
|
|
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSeoDataArticleReads(t *testing.T) {
|
|
allFields := []string{"slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
|
|
otherTenFields := []string{"seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
|
|
|
|
t.Run("Single-article reads carry all twelve fields", func(t *testing.T) {
|
|
requests := []struct{ method, path string }{
|
|
{http.MethodGet, "/v1/article/8"},
|
|
{http.MethodPatch, "/v1/article/8"},
|
|
{http.MethodPatch, "/v1/article/8/publish"},
|
|
{http.MethodPatch, "/v1/article/8/unpublish"},
|
|
}
|
|
for _, request := range requests {
|
|
t.Run(request.method+" "+request.path, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoSpringID)
|
|
body := ""
|
|
if request.method == http.MethodPatch && !strings.Contains(request.path, "publish") {
|
|
body = `{"body": "same"}`
|
|
}
|
|
|
|
rec := e.owner(t, request.method, request.path, body)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
decoded := decodeSeoObject(t, rec)
|
|
stored := e.stored(seoSpringID)
|
|
want := map[string]any{
|
|
"slug": stored.Slug, "excerpt": "Short teaser.", "seo_title": "Spring release notes", "seo_description": "Everything new this spring.",
|
|
"cover_image_id": float64(5), "cover_image_alt": "A field of tulips", "social_title": "Spring is here", "social_description": "Read the notes.",
|
|
"social_image_id": float64(6), "canonical_url": "https://www.example.com/spring", "noindex": true, "seo_dismissed": []any{"cover_alt", "short"},
|
|
}
|
|
for _, key := range allFields {
|
|
if !reflect.DeepEqual(decoded[key], want[key]) {
|
|
t.Errorf("%s = %v, expected %v", key, decoded[key], want[key])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A new article has the defaults", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
decoded := decodeSeoObject(t, rec)
|
|
want := map[string]any{"slug": "spring-release", "excerpt": "", "seo_title": "", "seo_description": "", "cover_image_id": nil, "cover_image_alt": "", "social_title": "", "social_description": "", "social_image_id": nil, "canonical_url": "", "noindex": false, "seo_dismissed": []any{}}
|
|
for key, value := range want {
|
|
if !reflect.DeepEqual(decoded[key], value) {
|
|
t.Errorf("%s = %#v, expected %#v", key, decoded[key], value)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The list carries the excerpt and the slug", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoSpringID)
|
|
stored := e.stored(seoSpringID)
|
|
stored.Slug = "spring-release"
|
|
e.articles.articles[seoSpringID] = stored
|
|
e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes")
|
|
|
|
rec := e.owner(t, http.MethodGet, "/v1/article/", "")
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
decoded := decodeSeoObject(t, rec)
|
|
items := decoded["items"].([]any)
|
|
if len(items) != 2 || decoded["total"] != float64(2) || decoded["page"] != float64(1) || decoded["size"] != float64(25) || decoded["counts"] == nil {
|
|
t.Fatalf("list = %v", decoded)
|
|
}
|
|
for _, raw := range items {
|
|
item := raw.(map[string]any)
|
|
if _, ok := item["excerpt"]; !ok {
|
|
t.Errorf("item without excerpt: %v", item)
|
|
}
|
|
if _, ok := item["slug"]; !ok {
|
|
t.Errorf("item without slug: %v", item)
|
|
}
|
|
for _, key := range otherTenFields {
|
|
if _, present := item[key]; present {
|
|
t.Errorf("item carries %s: %v", key, item)
|
|
}
|
|
}
|
|
if item["id"] == float64(seoSpringID) && (item["excerpt"] != "Short teaser." || item["slug"] != "spring-release") {
|
|
t.Errorf("article 8 item = %v", item)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Anonymous readers see the SEO fields of online articles", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedFullyOptimised(seoOtherID)
|
|
online := e.stored(seoOtherID)
|
|
online.IsOnline = true
|
|
e.articles.articles[seoOtherID] = online
|
|
|
|
rec := e.as(t, 0, http.MethodGet, "/v1/article/9", "")
|
|
|
|
decoded := decodeSeoObject(t, rec)
|
|
if rec.Code != http.StatusOK || decoded["excerpt"] != "Short teaser." || decoded["canonical_url"] != "https://www.example.com/spring" {
|
|
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("An offline article is still not found publicly", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
rec := e.as(t, 0, http.MethodGet, "/v1/article/8", "")
|
|
unverified := e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/article/8", "")
|
|
|
|
expectStatusAndText(t, rec, http.StatusNotFound, "Article not found")
|
|
expectStatusAndText(t, unverified, http.StatusNotFound, "Article not found")
|
|
})
|
|
|
|
t.Run("The list for anonymous callers shows online articles only", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
online := e.seedArticle(seoOtherID, "Autumn notes", siteMainID, "autumn-notes")
|
|
online.IsOnline = true
|
|
online.Excerpt = "Leaves."
|
|
e.articles.articles[seoOtherID] = online
|
|
|
|
rec := e.as(t, 0, http.MethodGet, "/v1/article/", "")
|
|
|
|
items := decodeSeoObject(t, rec)["items"].([]any)
|
|
if rec.Code != http.StatusOK || len(items) != 1 {
|
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
item := items[0].(map[string]any)
|
|
if item["excerpt"] != "Leaves." || item["slug"] != "autumn-notes" {
|
|
t.Errorf("item = %v", item)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSeoDataSlugGeneration(t *testing.T) {
|
|
t.Run("The slug is generated from the title at creation", func(t *testing.T) {
|
|
for _, c := range []struct{ title, slug string }{
|
|
{"Spring release", "spring-release"},
|
|
{"Été à Paris", "ete-a-paris"},
|
|
{"Dr. O'Neil Smith", "dr-o-neil-smith"},
|
|
{"!!!", "article"},
|
|
} {
|
|
t.Run(c.title, func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, c.title)+`, "body": "x"}`)
|
|
|
|
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != c.slug {
|
|
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A long title gives a slug of at most 100 characters", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
title := strings.TrimSpace(repeated("longword ", 22))
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`)
|
|
|
|
slug := decodeSeoObject(t, rec)["slug"].(string)
|
|
if rec.Code != http.StatusCreated || len(slug) > 100 || strings.HasSuffix(slug, "-") || !article.IsValidSlug(slug) {
|
|
t.Errorf("status %d slug %q (%d)", rec.Code, slug, len(slug))
|
|
}
|
|
})
|
|
|
|
t.Run("A generated slug that is taken on the site is de-duplicated silently", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(1, "One", siteMainID, "spring-release")
|
|
e.seedArticle(2, "Two", siteMainID, "spring-release-2")
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
|
|
|
|
if rec.Code != http.StatusCreated || decodeSeoObject(t, rec)["slug"] != "spring-release-3" {
|
|
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("A title without letters or digits is de-duplicated too", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(1, "One", siteMainID, "article")
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "!!!", "body": "x"}`)
|
|
|
|
if decodeSeoObject(t, rec)["slug"] != "article-2" {
|
|
t.Errorf("body %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("The suffix still fits the slug limit at creation", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
title := repeated("a", 100)
|
|
e.seedArticle(1, "One", siteMainID, title)
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": `+jsonString(t, title)+`, "body": "x"}`)
|
|
|
|
slug := decodeSeoObject(t, rec)["slug"].(string)
|
|
if rec.Code != http.StatusCreated || len(slug) != 100 || !strings.HasSuffix(slug, "-2") {
|
|
t.Errorf("status %d slug %q", rec.Code, slug)
|
|
}
|
|
})
|
|
|
|
t.Run("The same title on another site keeps the plain slug", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(1, "One", siteMainID, "spring-release")
|
|
e.sites.seed(siteShopID, "Shop", "shop", true)
|
|
defaultSite := e.sites.sites[siteMainID]
|
|
defaultSite.IsDefault = false
|
|
e.sites.sites[siteMainID] = defaultSite
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
|
|
|
|
decoded := decodeSeoObject(t, rec)
|
|
if decoded["slug"] != "spring-release" || decoded["site_id"] != float64(siteShopID) {
|
|
t.Errorf("body %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("A new article is created on the default site, so uniqueness uses that site", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.sites.seed(siteMainID, "Main", "main", false)
|
|
e.sites.seed(siteShopID, "Shop", "shop", true)
|
|
e.seedArticle(1, "One", siteShopID, "spring-release")
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "x"}`)
|
|
|
|
if decodeSeoObject(t, rec)["slug"] != "spring-release-2" {
|
|
t.Errorf("body %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSeoDataSlugEditing(t *testing.T) {
|
|
assertConflict := func(t *testing.T, rec interface {
|
|
Result() *http.Response
|
|
}, status int, suggestion string) {
|
|
t.Helper()
|
|
response := rec.Result()
|
|
if response.StatusCode != status || response.Header.Get(seoSlugHeader) != suggestion {
|
|
t.Errorf("status %d suggestion %q, expected %d %q", response.StatusCode, response.Header.Get(seoSlugHeader), status, suggestion)
|
|
}
|
|
}
|
|
|
|
t.Run("The same slug is allowed on two sites", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
|
|
e.seedArticle(seoOtherID, "Other", siteShopID, "other")
|
|
|
|
rec := e.patch(t, seoOtherID, `{"slug": "spring"}`)
|
|
|
|
if rec.Code != http.StatusOK || e.stored(seoOtherID).Slug != "spring" {
|
|
t.Errorf("status %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("The slug is editable at any time", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
stored := e.seedSpring()
|
|
stored.IsOnline = true
|
|
e.articles.articles[seoSpringID] = stored
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "spring-2026"}`)
|
|
|
|
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring-2026" || !e.stored(seoSpringID).IsOnline {
|
|
t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID))
|
|
}
|
|
})
|
|
|
|
t.Run("Keeping the own slug is not a collision", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "spring-release", "excerpt": "x"}`)
|
|
|
|
if rec.Code != http.StatusOK || e.stored(seoSpringID).Excerpt != "x" {
|
|
t.Errorf("status %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("A taken slug is refused with a suggestion", func(t *testing.T) {
|
|
for _, c := range []struct {
|
|
taken []string
|
|
suggestion string
|
|
}{
|
|
{[]string{"spring"}, "spring-2"},
|
|
{[]string{"spring", "spring-2"}, "spring-3"},
|
|
{[]string{"spring", "spring-2", "spring-3"}, "spring-4"},
|
|
} {
|
|
t.Run(strings.Join(c.taken, ","), func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
for index, slug := range c.taken {
|
|
e.seedArticle(uint32(20+index), "Taken "+slug, siteMainID, slug)
|
|
}
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
|
|
|
|
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
|
|
assertConflict(t, rec, http.StatusConflict, c.suggestion)
|
|
if !reflect.DeepEqual(e.stored(seoSpringID), before) {
|
|
t.Errorf("the article changed")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The suggestion fits the slug limit", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
long := repeated("a", 100)
|
|
e.seedArticle(20, "Long", siteMainID, long)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`)
|
|
|
|
suggestion := rec.Result().Header.Get(seoSlugHeader)
|
|
if rec.Code != http.StatusConflict || len(suggestion) != 100 || !strings.HasSuffix(suggestion, "-2") {
|
|
t.Errorf("status %d suggestion %q", rec.Code, suggestion)
|
|
}
|
|
})
|
|
|
|
t.Run("The suggestion skips every taken suffix and still fits 100 characters", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
long := repeated("a", 100)
|
|
e.seedArticle(20, "Long", siteMainID, long)
|
|
e.seedArticle(21, "Long 2", siteMainID, long[:98]+"-2")
|
|
e.seedArticle(22, "Long 3", siteMainID, long[:98]+"-3")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "`+long+`"}`)
|
|
|
|
suggestion := rec.Result().Header.Get(seoSlugHeader)
|
|
if rec.Code != http.StatusConflict || suggestion != long[:98]+"-4" || len(suggestion) != 100 {
|
|
t.Errorf("status %d suggestion %q", rec.Code, suggestion)
|
|
}
|
|
})
|
|
|
|
t.Run("The suggestion ignores the article itself", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring-2")
|
|
e.seedArticle(seoOtherID, "Other", siteMainID, "spring")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
|
|
|
|
assertConflict(t, rec, http.StatusConflict, "spring-2")
|
|
})
|
|
|
|
t.Run("The suggestion header is only set on the slug conflict", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.seedArticle(20, "Taken", siteMainID, "taken")
|
|
requests := []struct{ method, path, body string }{
|
|
{http.MethodPatch, "/v1/article/8", `{"slug": "fresh"}`},
|
|
{http.MethodPatch, "/v1/article/8", `{"slug": "Bad Slug"}`},
|
|
{http.MethodPatch, "/v1/article/999", `{"slug": "fresh"}`},
|
|
{http.MethodGet, "/v1/article/8", ""},
|
|
{http.MethodGet, "/v1/article/", ""},
|
|
{http.MethodPost, "/v1/article", `{"title": "Taken", "body": "x"}`},
|
|
}
|
|
|
|
for _, request := range requests {
|
|
rec := e.owner(t, request.method, request.path, request.body)
|
|
if header := rec.Result().Header.Get(seoSlugHeader); header != "" {
|
|
t.Errorf("%s %s answered %d with %s: %q", request.method, request.path, rec.Code, seoSlugHeader, header)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The repository can report a race", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.articles.reportSlugRace = true
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "fresh-looking"}`)
|
|
|
|
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
|
|
assertConflict(t, rec, http.StatusConflict, "fresh-looking-2")
|
|
if !reflect.DeepEqual(e.stored(seoSpringID), before) {
|
|
t.Errorf("the article changed")
|
|
}
|
|
})
|
|
|
|
t.Run("An empty slug regenerates it from the title", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom-address")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
|
|
|
|
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
|
|
t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug)
|
|
}
|
|
})
|
|
|
|
t.Run("Regeneration follows the creation rule", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoSpringID, "!!!", siteMainID, "custom")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
|
|
|
|
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "article" {
|
|
t.Errorf("status %d slug %q", rec.Code, e.stored(seoSpringID).Slug)
|
|
}
|
|
})
|
|
|
|
t.Run("Regenerating to the slug it already has is a no-op", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
|
|
|
|
if rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
|
|
t.Errorf("status %d", rec.Code)
|
|
}
|
|
e.noEvent(t)
|
|
})
|
|
|
|
t.Run("Regeneration that collides is refused, never silently suffixed", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoOtherID, "Other", siteMainID, seoSpringSlug)
|
|
e.seedArticle(seoSpringID, seoSpringTitle, siteMainID, "custom")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": ""}`)
|
|
|
|
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
|
|
assertConflict(t, rec, http.StatusConflict, "spring-release-2")
|
|
if e.stored(seoSpringID).Slug != "custom" {
|
|
t.Errorf("slug %q", e.stored(seoSpringID).Slug)
|
|
}
|
|
})
|
|
|
|
t.Run("Regeneration combined with a move uses the target site", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"site_id": 2, "slug": ""}`)
|
|
|
|
assertConflict(t, rec, http.StatusConflict, "spring-release-2")
|
|
})
|
|
|
|
t.Run("Moving to another site re-checks the slug on the target", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
|
|
e.seedArticle(seoOtherID, "Other", siteShopID, "spring")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
|
|
|
|
expectStatusAndText(t, rec, http.StatusConflict, seoSlugTaken)
|
|
assertConflict(t, rec, http.StatusConflict, "spring-2")
|
|
if stored := e.stored(seoSpringID); stored.SiteID != siteMainID || stored.Slug != "spring" {
|
|
t.Errorf("stored %+v", stored)
|
|
}
|
|
})
|
|
|
|
t.Run("Moving to a site where the slug is free keeps the slug", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
|
|
|
|
if stored := e.stored(seoSpringID); rec.Code != http.StatusOK || stored.SiteID != 2 || stored.Slug != seoSpringSlug {
|
|
t.Errorf("status %d stored %+v", rec.Code, stored)
|
|
}
|
|
})
|
|
|
|
t.Run("Moving and changing the slug together checks the new slug on the target", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.seedArticle(seoOtherID, "Other", siteMainID, "elsewhere")
|
|
e.seedArticle(30, "On shop", siteShopID, "spring")
|
|
|
|
moved := e.patch(t, seoSpringID, `{"site_id": 2, "slug": "autumn"}`)
|
|
refused := e.patch(t, seoOtherID, `{"site_id": 2, "slug": "spring"}`)
|
|
|
|
if moved.Code != http.StatusOK || e.stored(seoSpringID).Slug != "autumn" {
|
|
t.Errorf("move status %d", moved.Code)
|
|
}
|
|
assertConflict(t, refused, http.StatusConflict, "spring-2")
|
|
})
|
|
|
|
t.Run("A refused move changes no SEO field", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.seedArticle(seoOtherID, "Other", siteShopID, seoSpringSlug)
|
|
before := e.stored(seoSpringID)
|
|
|
|
rec := e.patch(t, seoSpringID, `{"site_id": 2, "excerpt": "new"}`)
|
|
|
|
if rec.Code != http.StatusConflict || !reflect.DeepEqual(e.stored(seoSpringID), before) {
|
|
t.Errorf("status %d stored %+v", rec.Code, e.stored(seoSpringID))
|
|
}
|
|
})
|
|
|
|
t.Run("A freed slug can be reused", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoSpringID, "Spring", siteMainID, "autumn")
|
|
e.seedArticle(seoOtherID, "Other", siteMainID, "spring")
|
|
deleted := e.owner(t, http.MethodDelete, "/v1/article/9", "")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
|
|
|
|
if deleted.Code != http.StatusNoContent || rec.Code != http.StatusOK || e.stored(seoSpringID).Slug != "spring" {
|
|
t.Errorf("delete %d, patch %d", deleted.Code, rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("Publishing and unpublishing never change the slug", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
|
|
published := e.owner(t, http.MethodPatch, "/v1/article/8/publish", "")
|
|
unpublished := e.owner(t, http.MethodPatch, "/v1/article/8/unpublish", "")
|
|
|
|
if published.Code != http.StatusOK || unpublished.Code != http.StatusOK || e.stored(seoSpringID).Slug != seoSpringSlug {
|
|
t.Errorf("publish %d unpublish %d slug %q", published.Code, unpublished.Code, e.stored(seoSpringID).Slug)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSeoDataSlugSuggestionUseCase(t *testing.T) {
|
|
t.Run("The suggestion is the first free suffix", func(t *testing.T) {
|
|
for taken := 0; taken < 6; taken++ {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.seedArticle(20, "Base", siteMainID, "spring")
|
|
for attempt := 2; attempt < 2+taken; attempt++ {
|
|
e.seedArticle(uint32(20+attempt), "Suffix "+strconv.Itoa(attempt), siteMainID, "spring-"+strconv.Itoa(attempt))
|
|
}
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "spring"}`)
|
|
|
|
want := "spring-" + strconv.Itoa(2+taken)
|
|
if got := rec.Result().Header.Get(seoSlugHeader); got != want {
|
|
t.Errorf("with %d suffixes taken: %q, expected %q", taken, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The suggestion is computed on the target site of a move", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedArticle(seoSpringID, "Spring", siteMainID, "spring")
|
|
e.seedArticle(20, "Main also", siteMainID, "spring-2")
|
|
e.seedArticle(21, "Shop", siteShopID, "spring")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"site_id": 2}`)
|
|
|
|
if got := rec.Result().Header.Get(seoSlugHeader); got != "spring-2" {
|
|
t.Errorf("suggestion %q, expected the slug that is free on the shop", got)
|
|
}
|
|
})
|
|
|
|
t.Run("A suggestion lookup failure is an internal error without header", func(t *testing.T) {
|
|
e := newSeoEnv(t)
|
|
e.seedSpring()
|
|
e.seedArticle(20, "Taken", siteMainID, "taken")
|
|
e.articles.fail.failNext("SlugTaken")
|
|
|
|
rec := e.patch(t, seoSpringID, `{"slug": "fresh"}`)
|
|
|
|
if rec.Code != http.StatusInternalServerError || rec.Result().Header.Get(seoSlugHeader) != "" {
|
|
t.Errorf("status %d", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSeoDataDismissCodes(t *testing.T) {
|
|
t.Run("The dismissed codes are the eleven suggestion codes", func(t *testing.T) {
|
|
if got := fmt.Sprint(article.DismissCodes()); got != "[title_long title_empty desc_short desc_long cover_alt cover_none slug_long no_au no_h body_alt short]" {
|
|
t.Errorf("codes = %s", got)
|
|
}
|
|
})
|
|
}
|