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

described`) }) } 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, `okfine`)), "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) } }) }