Files
RenewCMS/test/seo_data_article_http_test.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)
}
})
}