feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -0,0 +1,943 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user