432 lines
17 KiB
Go
432 lines
17 KiB
Go
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, `<h2>Heading</h2><img src="/x.png" alt="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, `<img src="a" alt="ok"><img src="b" alt=""><img src="c" alt="fine">`)), "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, "<h2>x</h2>")), "no_h", false},
|
|
{jsonBody(t, "body", textBody(1000, "<h6>x</h6>")), "no_h", false},
|
|
{jsonBody(t, "body", textBody(1000, "<h1>x</h1>")), "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(`<span class="x">`, 25) + strings.Repeat("</span>", 25)
|
|
body := "<p>" + strings.Repeat("a", 200) + "</p>" + 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", "<p>"+sentence+" Two.</p>"))
|
|
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, `<img src="a">`)))
|
|
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": "<p>x</p>", "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, `<h2>x</h2>`)
|
|
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)
|
|
}
|
|
})
|
|
}
|