package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"net/http"
"reflect"
"strings"
"testing"
)
func seoOutFullyPrepared(e *seoOutEnv) {
e.credit(seoOutAuthorMarta, "Marta Alves", "")
e.change(12, func(a *article.Article) {
a.SEOTitle = repeated("t", 45)
a.SEODescription = repeated("d", 100)
a.CoverImageID, a.CoverImageAlt = idPointer(5), "A field"
a.Body = textBody(400, `
Heading
`)
})
}
func findSuggestion(checked map[string]any, code string) map[string]any {
for _, item := range checked["suggestions"].([]any) {
if found := item.(map[string]any); found["code"] == code {
return found
}
}
return nil
}
func TestSeoOutputCheckShapeAndCodes(t *testing.T) {
t.Run("The check answers the documented shape", func(t *testing.T) {
e := newSeoOutEnv(t)
checked := e.check(t, `{}`)
if sortedKeys(checked) != "slug,status,suggestions" || sortedKeys(nested(checked, "slug")) != "available,suggestion" {
t.Errorf("checked = %v", checked)
}
for _, item := range checked["suggestions"].([]any) {
if sortedKeys(item.(map[string]any)) != "code,field,params" {
t.Errorf("suggestion = %v", item)
}
}
})
t.Run("Each code fires on its condition", func(t *testing.T) {
cases := []struct {
name string
setup func(*seoOutEnv)
body string
code string
field string
params map[string]any
}{
{"title_long", nil, jsonBody(t, "seo_title", repeated("t", 74)), "title_long", "seo_title", map[string]any{"length": 74.0, "max": 60.0}},
{"title_empty", nil, `{"seo_title": " "}`, "title_empty", "seo_title", map[string]any{}},
{"desc_short", nil, jsonBody(t, "seo_description", repeated("d", 41)), "desc_short", "seo_description", map[string]any{"length": 41.0, "min": 70.0}},
{"desc_long", nil, jsonBody(t, "seo_description", repeated("d", 170)), "desc_long", "seo_description", map[string]any{"length": 170.0, "max": 160.0}},
{"cover_alt", func(e *seoOutEnv) { e.change(12, func(a *article.Article) { a.CoverImageID = idPointer(5) }) }, `{}`, "cover_alt", "cover_image_alt", map[string]any{}},
{"cover_none", nil, `{}`, "cover_none", "cover_image_id", map[string]any{}},
{"slug_long", nil, jsonBody(t, "slug", repeated("s", 76)), "slug_long", "slug", map[string]any{"length": 76.0, "max": 75.0}},
{"no_au", nil, `{}`, "no_au", "author_ids", map[string]any{}},
{"no_h", nil, jsonBody(t, "body", textBody(1000, "")), "no_h", "body", map[string]any{}},
{"body_alt", nil, jsonBody(t, "body", textBody(400, `

`)), "body_alt", "body", map[string]any{"count": 1.0}},
{"short", nil, jsonBody(t, "body", textBody(40, "")), "short", "body", map[string]any{"length": 40.0, "min": 300.0}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
if c.setup != nil {
c.setup(e)
}
found := findSuggestion(e.check(t, c.body), c.code)
if found == nil || found["field"] != c.field || !reflect.DeepEqual(found["params"], c.params) {
t.Errorf("suggestion = %v", found)
}
})
}
})
t.Run("Soft ranges are inclusive at the edges", func(t *testing.T) {
cases := []struct {
body string
code string
present bool
}{
{jsonBody(t, "seo_title", repeated("t", 60)), "title_long", false},
{jsonBody(t, "seo_title", repeated("t", 61)), "title_long", true},
{jsonBody(t, "seo_description", repeated("d", 70)), "desc_short", false},
{jsonBody(t, "seo_description", repeated("d", 69)), "desc_short", true},
{jsonBody(t, "seo_description", repeated("d", 160)), "desc_long", false},
{jsonBody(t, "seo_description", repeated("d", 161)), "desc_long", true},
{jsonBody(t, "slug", repeated("s", 75)), "slug_long", false},
{jsonBody(t, "slug", repeated("s", 76)), "slug_long", true},
{jsonBody(t, "body", textBody(999, "")), "no_h", false},
{jsonBody(t, "body", textBody(1000, "")), "no_h", true},
{jsonBody(t, "body", textBody(1000, "x
")), "no_h", false},
{jsonBody(t, "body", textBody(1000, "x
")), "no_h", false},
{jsonBody(t, "body", textBody(1000, "x
")), "no_h", true},
{jsonBody(t, "body", textBody(300, "")), "short", false},
{jsonBody(t, "body", textBody(299, "")), "short", true},
}
for _, c := range cases {
e := newSeoOutEnv(t)
if got := hasCode(e.checkCodes(t, c.body), c.code); got != c.present {
t.Errorf("%.40s %s: present %v, expected %v", c.body, c.code, got, c.present)
}
}
})
t.Run("Lengths are counted in runes", func(t *testing.T) {
e := newSeoOutEnv(t)
if hasCode(e.checkCodes(t, jsonBody(t, "seo_title", repeated("é", 60))), "title_long") {
t.Error("title_long fired for 60 two-byte characters")
}
})
t.Run("The title range is measured on the meta title, not on the template output", func(t *testing.T) {
e := newSeoOutEnv(t)
e.changeSiteTemplate("Thirty chars of prefix here: %title%")
if hasCode(e.checkCodes(t, jsonBody(t, "seo_title", repeated("t", 40))), "title_long") {
t.Error("title_long fired")
}
})
t.Run("Text length ignores tags", func(t *testing.T) {
e := newSeoOutEnv(t)
markup := strings.Repeat(``, 25) + strings.Repeat("", 25)
body := "" + strings.Repeat("a", 200) + "
" + markup
if !hasCode(e.checkCodes(t, jsonBody(t, "body", body)), "short") {
t.Error("short absent")
}
})
t.Run("A fully prepared article is ready", func(t *testing.T) {
e := newSeoOutEnv(t)
seoOutFullyPrepared(e)
checked := e.check(t, `{}`)
if checked["status"] != "ready" || !reflect.DeepEqual(checked["suggestions"], []any{}) {
t.Errorf("checked = %v", checked)
}
})
}
func TestSeoOutputCheckOverrides(t *testing.T) {
t.Run("Body overrides replace saved values for the check only", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.SEOTitle = "Short" })
before := e.stored(12)
if !hasCode(e.checkCodes(t, jsonBody(t, "seo_title", repeated("t", 74))), "title_long") {
t.Error("title_long absent")
}
if !reflect.DeepEqual(e.stored(12), before) {
t.Error("the article changed")
}
})
t.Run("Overrides can also clear a saved value", func(t *testing.T) {
cases := []struct {
name string
saved func(*article.Article)
body string
code string
present bool
}{
{"null cover", func(a *article.Article) { a.CoverImageID = idPointer(5) }, `{"cover_image_id": null}`, "cover_alt", false},
{"alt given", func(a *article.Article) { a.CoverImageID = idPointer(5) }, `{"cover_image_alt": "A field"}`, "cover_alt", false},
{"alt cleared", func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = idPointer(5), "A field" }, `{"cover_image_alt": ""}`, "cover_alt", true},
{"seo title cleared", func(a *article.Article) { a.SEOTitle, a.Title = repeated("t", 74), repeated("u", 20) }, `{"seo_title": ""}`, "title_long", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, c.saved)
if got := hasCode(e.checkCodes(t, c.body), c.code); got != c.present {
t.Errorf("present %v", got)
}
})
}
})
t.Run("A body override feeds the body rules and the description fallback", func(t *testing.T) {
e := newSeoOutEnv(t)
sentence := "Sentence one of exactly seventy characters, padded here to reach the minimum."
codes := e.checkCodes(t, jsonBody(t, "body", ""+sentence+" Two.
"))
if hasCode(codes, "desc_short") || !hasCode(codes, "short") || e.stored(12).Body != seoOutBody {
t.Errorf("codes = %v", codes)
}
})
t.Run("A body override with images and headings", func(t *testing.T) {
e := newSeoOutEnv(t)
codes := e.checkCodes(t, jsonBody(t, "body", textBody(1000, `
`)))
if !hasCode(codes, "no_h") || !hasCode(codes, "body_alt") {
t.Errorf("codes = %v", codes)
}
})
t.Run("The check is non-persisting", func(t *testing.T) {
e := newSeoOutEnv(t)
before := e.stored(12)
siteBefore := e.sites.sites[siteMainID]
e.check(t, `{"slug": "other", "noindex": true, "excerpt": "x", "body": "x
", "seo_dismissed": ["short"]}`)
if !reflect.DeepEqual(e.stored(12), before) || !reflect.DeepEqual(e.sites.sites[siteMainID], siteBefore) || e.counted.writes != 0 {
t.Errorf("writes %d", e.counted.writes)
}
e.noEvent(t)
})
t.Run("Dismissed codes are filtered out", func(t *testing.T) {
e := newSeoOutEnv(t)
seoOutFullyPrepared(e)
e.change(12, func(a *article.Article) {
a.CoverImageID, a.CoverImageAlt = nil, ""
a.Body = textBody(100, `x
`)
a.SEODismissed = []string{"cover_none", "no_au"}
})
e.articles.credits[12] = nil
if codes := e.checkCodes(t, `{}`); !reflect.DeepEqual(codes, []string{"short"}) {
t.Errorf("codes = %v", codes)
}
})
t.Run("A dismissed override in the body filters the check", func(t *testing.T) {
e := newSeoOutEnv(t)
seoOutFullyPrepared(e)
e.articles.credits[12] = nil
e.change(12, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt, a.Body = nil, "", textBody(1000, "") })
checked := e.check(t, `{"seo_dismissed": ["no_au", "cover_none", "no_h"]}`)
if checked["status"] != "ready" || len(checked["suggestions"].([]any)) != 0 {
t.Errorf("checked = %v", checked)
}
e.change(12, func(a *article.Article) { a.Body = textBody(100, "") })
checked = e.check(t, `{"seo_dismissed": ["short", "no_au", "cover_none", "no_h"]}`)
if checked["status"] != "ready" || len(checked["suggestions"].([]any)) != 0 {
t.Errorf("checked = %v", checked)
}
})
t.Run("Dismissing everything leaves ready, noindex still hides", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.Noindex = true })
all := `["title_long","title_empty","desc_short","desc_long","cover_alt","cover_none","slug_long","no_au","no_h","body_alt","short"]`
checked := e.check(t, `{"seo_dismissed": `+all+`}`)
if checked["status"] != "hidden" || len(checked["suggestions"].([]any)) != 0 {
t.Errorf("checked = %v", checked)
}
})
t.Run("The status", func(t *testing.T) {
cases := []struct {
name string
prepare func(*seoOutEnv)
noindex bool
status string
}{
{"ready", seoOutFullyPrepared, false, "ready"},
{"suggestions", func(e *seoOutEnv) {}, false, "suggestions"},
{"hidden without suggestions", seoOutFullyPrepared, true, "hidden"},
{"hidden with suggestions", func(e *seoOutEnv) {}, true, "hidden"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
c.prepare(e)
e.change(12, func(a *article.Article) { a.Noindex = c.noindex })
if got := e.check(t, `{}`)["status"]; got != c.status {
t.Errorf("status %v", got)
}
})
}
})
t.Run("A noindex override gives hidden without saving", func(t *testing.T) {
e := newSeoOutEnv(t)
if e.check(t, `{"noindex": true}`)["status"] != "hidden" || e.stored(12).Noindex {
t.Error("unexpected")
}
})
t.Run("Suggestions come in a fixed order", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) {
a.SEOTitle = repeated("t", 70)
a.CoverImageID, a.CoverImageAlt = idPointer(5), "ok"
a.SEODescription = repeated("d", 100)
})
if codes := e.checkCodes(t, `{}`); !reflect.DeepEqual(codes, []string{"title_long", "no_au", "short"}) {
t.Errorf("codes = %v", codes)
}
})
}
func TestSeoOutputCheckSlugAndValidation(t *testing.T) {
slugOf := func(checked map[string]any) map[string]any { return nested(checked, "slug") }
seed := func(e *seoOutEnv) {
e.seedArticle(14, "Spring", siteMainID, "spring")
e.seedArticle(15, "Spring two", siteMainID, "spring-2")
e.seedArticle(16, "Autumn", seoOutJournalID, "autumn")
}
t.Run("Slug availability on the article's site", func(t *testing.T) {
cases := []struct {
body string
available bool
suggestion any
}{
{`{}`, true, nil},
{`{"slug": "how-we-price-a-bathroom-renovation"}`, true, nil},
{`{"slug": "fresh"}`, true, nil},
{`{"slug": "autumn"}`, true, nil},
{`{"slug": "spring"}`, false, "spring-3"},
{`{"slug": ""}`, true, nil},
}
for _, c := range cases {
e := newSeoOutEnv(t)
seed(e)
got := slugOf(e.check(t, c.body))
if got["available"] != c.available || got["suggestion"] != c.suggestion {
t.Errorf("%s: %v", c.body, got)
}
}
})
t.Run("A regenerated empty slug can be taken", func(t *testing.T) {
e := newSeoOutEnv(t)
e.change(12, func(a *article.Article) { a.Slug = "custom" })
e.seedArticle(14, "Other", siteMainID, seoOutArticleSlug)
got := slugOf(e.check(t, `{"slug": ""}`))
if got["available"] != false || got["suggestion"] != seoOutArticleSlug+"-2" {
t.Errorf("slug = %v", got)
}
})
t.Run("The suggestion fits the slug limit", func(t *testing.T) {
e := newSeoOutEnv(t)
long := repeated("a", 100)
e.seedArticle(14, "Long", siteMainID, long)
got := slugOf(e.check(t, jsonBody(t, "slug", long)))
suggestion, _ := got["suggestion"].(string)
if len(suggestion) > 100 || !strings.HasSuffix(suggestion, "-2") {
t.Errorf("slug = %v", got)
}
})
t.Run("A taken slug is not a refusal", func(t *testing.T) {
e := newSeoOutEnv(t)
seed(e)
if rec := e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", `{"slug": "spring"}`); rec.Code != http.StatusOK {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The same validation as PATCH applies", func(t *testing.T) {
cases := []struct {
name, body string
status int
text string
}{
{"seo_title", jsonBody(t, "seo_title", repeated("a", 121)), 400, "validator"},
{"seo_description", jsonBody(t, "seo_description", repeated("a", 321)), 400, "validator"},
{"excerpt", jsonBody(t, "excerpt", repeated("a", 301)), 400, "validator"},
{"cover_image_alt", jsonBody(t, "cover_image_alt", repeated("a", 201)), 400, "validator"},
{"social_title", jsonBody(t, "social_title", repeated("a", 121)), 400, "validator"},
{"social_description", jsonBody(t, "social_description", repeated("a", 321)), 400, "validator"},
{"slug uppercase", `{"slug": "Spring"}`, 400, apperr.InvalidSlugErrorMessage},
{"slug long", jsonBody(t, "slug", repeated("a", 101)), 400, apperr.InvalidSlugErrorMessage},
{"canonical", `{"canonical_url": "http://example.com/a"}`, 422, apperr.InvalidURLErrorMessage},
{"cover missing", `{"cover_image_id": 999}`, 400, apperr.ArticleImageNotFoundErrorMessage},
{"social missing", `{"social_image_id": 999}`, 400, apperr.ArticleImageNotFoundErrorMessage},
{"dismissed", `{"seo_dismissed": ["nope"]}`, 400, "validator"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoOutEnv(t)
rec := e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", c.body)
if c.text == "validator" {
expectStatusAndValidatorText(t, rec, c.status)
return
}
expectStatusAndText(t, rec, c.status, c.text)
})
}
})
t.Run("Malformed bodies are refused", func(t *testing.T) {
for _, body := range []string{`{not json`, `{"slug": null}`, `{"noindex": null}`, `{"seo_dismissed": null}`, `{"body": null}`, `{"noindex": "yes"}`, `{"colour": "x"}`, `null`, `{"site_id": 2}`, `{"author_ids": [2]}`} {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", body), 400, apperr.BodyErrorMessage)
}
})
t.Run("Anyone who can edit the article may check it", func(t *testing.T) {
cases := []struct {
who string
status int
}{{"owner", 200}, {"admin", 200}, {"editor", 200}, {"key", 200}, {"unverified", 403}, {"", 401}}
for _, c := range cases {
e := newSeoOutEnv(t)
if rec := e.call(t, c.who, http.MethodPost, "/v1/article/12/seo/check", `{}`); rec.Code != c.status {
t.Errorf("%q: status %d", c.who, rec.Code)
}
}
})
t.Run("A draft can be checked", func(t *testing.T) {
e := newSeoOutEnv(t)
if rec := e.call(t, "owner", http.MethodPost, "/v1/article/13/seo/check", `{}`); rec.Code != http.StatusOK {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A missing article is not found", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodPost, "/v1/article/999/seo/check", `{}`), 404, apperr.ArticleNotFoundErrorMessage)
})
t.Run("A malformed id is refused", func(t *testing.T) {
e := newSeoOutEnv(t)
expectStatusAndText(t, e.call(t, "owner", http.MethodPost, "/v1/article/abc/seo/check", `{}`), 400, apperr.IdUint32ErrorMessage)
})
t.Run("Publishing is never blocked by suggestions", func(t *testing.T) {
e := newSeoOutEnv(t)
if len(e.check(t, `{}`)["suggestions"].([]any)) == 0 {
t.Fatal("expected suggestions")
}
if rec := e.call(t, "owner", http.MethodPatch, "/v1/article/13/publish", ""); rec.Code != http.StatusOK {
t.Errorf("status %d", rec.Code)
}
})
}