feat: SEO backend output (2/4)
This commit is contained in:
1 parent
0eb46b0d74
commit
eb9876fde3
32 files changed
+3859
-65
No files matched your search
@@ -103,6 +103,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
|
||||
sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) })
|
||||
start := min((filters.Page-1)*filters.Size, len(matching))
|
||||
end := min(start+filters.Size, len(matching))
|
||||
if filters.Unpaged {
|
||||
start, end = 0, len(matching)
|
||||
}
|
||||
items := make([]article.Article, 0)
|
||||
for _, a := range matching[start:end] {
|
||||
items = append(items, m.withAuthors(a))
|
||||
@@ -143,6 +146,8 @@ func articleSortsBefore(left, right article.Article, order article.Sort) bool {
|
||||
}
|
||||
case article.SortByCreatedAt:
|
||||
comparison = left.CreatedAt.Compare(right.CreatedAt)
|
||||
case article.SortByID:
|
||||
comparison = int(left.ID) - int(right.ID)
|
||||
default:
|
||||
comparison = left.UpdatedAt.Compare(right.UpdatedAt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
articlehandler "RenewCMS/api/handlers/article"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeoOutputListPagingNeverPanics(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
for id := uint32(200); id < 207; id++ {
|
||||
e.seedListed(id, "Listed "+strconv.Itoa(int(id)), true, nil)
|
||||
}
|
||||
run := func(page int, size int) useCases.ArticlePage {
|
||||
t.Helper()
|
||||
result, err := e.container.ListArticlesUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
Verified: true, Page: page, Size: size, SEO: article.SEOStatusReady, Sort: article.Sort{Field: "title", Direction: "asc"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("page %d size %d: %v", page, size, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
total := run(1, 100).Total
|
||||
if total != 7 {
|
||||
t.Fatalf("ready total = %d, want 7", total)
|
||||
}
|
||||
cases := []struct {
|
||||
page, size, items int
|
||||
}{
|
||||
{math.MaxInt, 25, 0},
|
||||
{math.MaxInt, math.MaxInt, 0},
|
||||
{1, -5, 0},
|
||||
{0, -5, 0},
|
||||
{1, 0, 0},
|
||||
{2, math.MaxInt, 0},
|
||||
{1, math.MaxInt, 7},
|
||||
{0, 5, 5},
|
||||
{-3, 5, 5},
|
||||
{2, 5, 2},
|
||||
{3, 5, 0},
|
||||
{2, 4, 3},
|
||||
{3, 4, 0},
|
||||
{math.MinInt, 5, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
result := run(c.page, c.size)
|
||||
if len(result.Items) != c.items || result.Total != total {
|
||||
t.Errorf("page %d size %d: items %d total %d, want items %d total %d", c.page, c.size, len(result.Items), result.Total, c.items, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeoOutputContractParity(t *testing.T) {
|
||||
oneof := "oneof=" + strings.Join(article.DismissCodes(), " ")
|
||||
max := "max=" + strconv.Itoa(article.MaxCanonicalURLRunes)
|
||||
for _, subject := range []reflect.Type{reflect.TypeOf(articlehandler.CheckArticleSEOBody{}), reflect.TypeOf(articlehandler.UpdateArticleBody{})} {
|
||||
dismissed, _ := subject.FieldByName("SEODismissed")
|
||||
if !strings.HasSuffix(dismissed.Tag.Get("validate"), oneof) {
|
||||
t.Errorf("%s seo_dismissed tag %q lacks %q", subject.Name(), dismissed.Tag.Get("validate"), oneof)
|
||||
}
|
||||
canonical, _ := subject.FieldByName("CanonicalURL")
|
||||
if !strings.Contains(canonical.Tag.Get("validate"), max) {
|
||||
t.Errorf("%s canonical_url tag %q lacks %q", subject.Name(), canonical.Tag.Get("validate"), max)
|
||||
}
|
||||
}
|
||||
e := newSeoOutEnv(t)
|
||||
for _, status := range []string{article.SEOStatusReady, article.SEOStatusSuggestions, article.SEOStatusHidden} {
|
||||
rec := e.call(t, "owner", http.MethodGet, "/v1/article/?seo="+status, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("seo=%s: %d", status, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := e.call(t, "owner", http.MethodGet, "/v1/article/?seo=bogus", ""); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("seo=bogus: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeoOutputLayering(t *testing.T) {
|
||||
forbidden := []string{"gorm", "net/http", "go-chi", "persistence", "mailer", "api/"}
|
||||
files := []string{
|
||||
"seoBody.go", "seoRules.go", "ResolveArticleSEO.go", "CheckArticleSEO.go", "ListSitemap.go", "ListArticles.go",
|
||||
}
|
||||
for _, name := range files {
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "internal", "infrastructure", "useCases", name), nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range parsed.Imports {
|
||||
for _, word := range forbidden {
|
||||
if strings.Contains(spec.Path.Value, word) {
|
||||
t.Errorf("%s imports %s", name, spec.Path.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"seoStatus.go", "seoResolved.go"} {
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "internal", "domain", "article", name), nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range parsed.Imports {
|
||||
if strings.Contains(spec.Path.Value, ".") {
|
||||
t.Errorf("%s imports %s", name, spec.Path.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (e *seoOutEnv) cleanArticles() {
|
||||
delete(e.articles.articles, seoOutArticleID)
|
||||
delete(e.articles.articles, seoOutDraftID)
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) seedListed(id uint32, title string, online bool, mutate func(*article.Article)) {
|
||||
e.credit(seoOutAuthorMarta, "Marta Alves", "")
|
||||
e.articles.credits[seoOutArticleID] = nil
|
||||
e.articles.credit(id, seoOutAuthorMarta)
|
||||
e.articles.articles[id] = article.Article{
|
||||
ID: id, Title: title, Body: textBody(400, "<h2>h</h2>"), IsOnline: online, SiteID: siteMainID, Slug: "slug-" + strconv.Itoa(int(id)),
|
||||
SEOTitle: repeated("t", 45), SEODescription: repeated("d", 100), CoverImageID: idPointer(5), CoverImageAlt: "alt",
|
||||
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutCreated.Add(time.Duration(id) * time.Hour),
|
||||
}
|
||||
if mutate != nil {
|
||||
found := e.articles.articles[id]
|
||||
mutate(&found)
|
||||
e.articles.articles[id] = found
|
||||
}
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) seedFourListed() {
|
||||
e.cleanArticles()
|
||||
e.seedListed(1, "Ready", true, nil)
|
||||
e.seedListed(2, "Two", true, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, ""; a.SEOTitle = repeated("t", 70) })
|
||||
e.seedListed(3, "Hidden", true, func(a *article.Article) {
|
||||
a.Noindex = true
|
||||
a.CoverImageID, a.CoverImageAlt = nil, ""
|
||||
a.SEOTitle = repeated("t", 70)
|
||||
})
|
||||
e.seedListed(4, "Four", true, func(a *article.Article) {
|
||||
a.CoverImageID, a.CoverImageAlt = nil, ""
|
||||
a.SEOTitle = repeated("t", 70)
|
||||
a.Slug = repeated("s", 80)
|
||||
a.SEODismissed = []string{"slug_long"}
|
||||
})
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) list(t *testing.T, who string, query string) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.call(t, who, http.MethodGet, "/v1/article/"+query, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list %s: %d %s", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
return decodeSeoObject(t, rec)
|
||||
}
|
||||
|
||||
func itemByID(list map[string]any, id int) map[string]any {
|
||||
for _, item := range list["items"].([]any) {
|
||||
if found := item.(map[string]any); int(found["id"].(float64)) == id {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSeoOutputList(t *testing.T) {
|
||||
t.Run("Items carry seo_status and seo_suggestions", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedFourListed()
|
||||
list := e.list(t, "owner", "")
|
||||
want := map[int][2]any{1: {"ready", 0.0}, 2: {"suggestions", 2.0}, 3: {"hidden", 2.0}, 4: {"suggestions", 2.0}}
|
||||
for id, expected := range want {
|
||||
item := itemByID(list, id)
|
||||
if item["seo_status"] != expected[0] || item["seo_suggestions"] != expected[1] {
|
||||
t.Errorf("item %d = %v %v", id, item["seo_status"], item["seo_suggestions"])
|
||||
}
|
||||
if _, present := item["seo_dismissed"]; present {
|
||||
t.Errorf("item %d carries seo_dismissed", id)
|
||||
}
|
||||
if _, present := item["excerpt"]; !present {
|
||||
t.Errorf("item %d has no excerpt", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The list and the check agree", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedFourListed()
|
||||
list := e.list(t, "owner", "")
|
||||
for id := 1; id <= 4; id++ {
|
||||
rec := e.call(t, "owner", http.MethodPost, "/v1/article/"+strconv.Itoa(id)+"/seo/check", `{}`)
|
||||
checked := decodeSeoObject(t, rec)
|
||||
item := itemByID(list, id)
|
||||
if checked["status"] != item["seo_status"] || float64(len(checked["suggestions"].([]any))) != item["seo_suggestions"] {
|
||||
t.Errorf("article %d: list %v/%v, check %v", id, item["seo_status"], item["seo_suggestions"], checked)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Anonymous callers see online items with their status", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedFourListed()
|
||||
e.seedListed(5, "Draft", false, nil)
|
||||
list := e.list(t, "", "")
|
||||
if !reflect.DeepEqual(listIDs(list), []int{4, 3, 2, 1}) || itemByID(list, 2)["seo_status"] != "suggestions" {
|
||||
t.Errorf("ids = %v", listIDs(list))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The seo filter", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
ids []int
|
||||
total float64
|
||||
}{{"ready", []int{1}, 1}, {"suggestions", []int{4, 2}, 2}, {"hidden", []int{3}, 1}}
|
||||
for _, c := range cases {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedFourListed()
|
||||
list := e.list(t, "owner", "?seo="+c.value)
|
||||
if !reflect.DeepEqual(listIDs(list), c.ids) || list["total"] != c.total {
|
||||
t.Errorf("%s: ids %v total %v", c.value, listIDs(list), list["total"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The filter composes with search, status, site, sort and paging", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
for id := uint32(1); id <= 7; id++ {
|
||||
e.seedListed(id, "Tile "+strconv.Itoa(int(id)), false, func(a *article.Article) { a.SiteID = seoOutJournalID; a.CoverImageID, a.CoverImageAlt = nil, "" })
|
||||
}
|
||||
for id := uint32(8); id <= 10; id++ {
|
||||
e.seedListed(id, "Tile "+strconv.Itoa(int(id)), true, func(a *article.Article) { a.SiteID = seoOutJournalID; a.CoverImageID, a.CoverImageAlt = nil, "" })
|
||||
}
|
||||
for id := uint32(11); id <= 14; id++ {
|
||||
e.seedListed(id, "Tile "+strconv.Itoa(int(id)), false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
|
||||
}
|
||||
base := "?seo=suggestions&q=tile&status=draft&site=journal&sort=title:asc&size=5&page="
|
||||
first, second := e.list(t, "owner", base+"1"), e.list(t, "owner", base+"2")
|
||||
if first["total"] != 7.0 || !reflect.DeepEqual(listIDs(second), []int{6, 7}) {
|
||||
t.Errorf("first %v second %v total %v", listIDs(first), listIDs(second), first["total"])
|
||||
}
|
||||
for _, id := range listIDs(first) {
|
||||
for _, other := range listIDs(second) {
|
||||
if id == other {
|
||||
t.Errorf("id %d on both pages", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Counts stay unfiltered", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
for id := uint32(1); id <= 6; id++ {
|
||||
online := id <= 4
|
||||
e.seedListed(id, "A"+strconv.Itoa(int(id)), online, func(a *article.Article) {
|
||||
if id%2 == 0 {
|
||||
a.CoverImageID, a.CoverImageAlt = nil, ""
|
||||
}
|
||||
})
|
||||
}
|
||||
list := e.list(t, "owner", "?seo=suggestions")
|
||||
if !reflect.DeepEqual(list["counts"], map[string]any{"all": 6.0, "online": 4.0, "draft": 2.0}) || list["total"] != 3.0 {
|
||||
t.Errorf("list = %v %v", list["counts"], list["total"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The filter is applied before paging", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
for id := uint32(1); id <= 30; id++ {
|
||||
e.seedListed(id, "A"+strconv.Itoa(int(id)), true, func(a *article.Article) {
|
||||
if id <= 12 {
|
||||
a.CoverImageID, a.CoverImageAlt = nil, ""
|
||||
}
|
||||
})
|
||||
}
|
||||
list := e.list(t, "owner", "?seo=suggestions&size=5&page=3")
|
||||
if len(listIDs(list)) != 2 || list["total"] != 12.0 {
|
||||
t.Errorf("ids %v total %v", listIDs(list), list["total"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The filter respects visibility", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
e.seedListed(1, "Offline", false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
|
||||
list := e.list(t, "", "?seo=suggestions")
|
||||
if len(listIDs(list)) != 0 || list["total"] != 0.0 {
|
||||
t.Errorf("list = %v", list)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Dismissed codes are never counted in the filter", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
e.seedListed(1, "Dismissed", true, func(a *article.Article) {
|
||||
a.CoverImageID, a.CoverImageAlt = nil, ""
|
||||
a.SEODismissed = []string{"cover_none"}
|
||||
})
|
||||
if ids := listIDs(e.list(t, "owner", "?seo=suggestions")); len(ids) != 0 {
|
||||
t.Errorf("ids = %v", ids)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An invalid seo value is refused", func(t *testing.T) {
|
||||
for _, query := range []string{"seo=maybe", "seo=", "seo=READY", "seo=ready,hidden", "seo=1"} {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedFourListed()
|
||||
before := e.counted.readCalls()
|
||||
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/?"+query, ""), 400, apperr.BodyErrorMessage)
|
||||
if e.counted.readCalls() != before {
|
||||
t.Errorf("%s: the repository was called", query)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid format is still reported first", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/?seo=maybe&format=pdf", ""), 400, apperr.ArticleBodyFormatErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("The existing list behaviour is unchanged without the filter", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
list := e.list(t, "owner", "")
|
||||
if list["total"] != 2.0 || list["page"] != 1.0 || list["size"] != 25.0 || !reflect.DeepEqual(list["counts"], map[string]any{"all": 2.0, "online": 1.0, "draft": 1.0}) {
|
||||
t.Errorf("list = %v", list)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const seoOutImageURL = seoOutBase + "/static/uploadedImages/"
|
||||
|
||||
func TestSeoOutputResolver(t *testing.T) {
|
||||
t.Run("The object has the documented shape", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
object := decodeSeoObject(t, rec)
|
||||
cases := map[string]string{
|
||||
"": "authors,canonical_url,description,fallbacks,image,json_ld,modified_at,og,published_at,robots,title,twitter",
|
||||
"og": "description,image,image_alt,locale,site_name,title,type,url",
|
||||
"twitter": "card,description,image,image_alt,title",
|
||||
"image": "alt,source,url",
|
||||
"fallbacks": "canonical,description,image,title",
|
||||
}
|
||||
for key, want := range cases {
|
||||
target := object
|
||||
if key != "" {
|
||||
target = nested(object, key)
|
||||
}
|
||||
if got := sortedKeys(target); got != want {
|
||||
t.Errorf("keys of %q = %s, expected %s", key, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A bare article resolves every output through the fallbacks", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
object := e.resolve(t, 12)
|
||||
title := seoOutArticleTitle + " | Renew"
|
||||
url := seoOutBase + "/" + seoOutArticleSlug
|
||||
image := seoOutImageURL + "7.png"
|
||||
want := map[string]any{
|
||||
"title": title, "description": "We price by the square metre.", "canonical_url": url, "robots": "index, follow",
|
||||
"image": map[string]any{"url": image, "alt": "", "source": "site"},
|
||||
"og": map[string]any{"type": "article", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": "", "url": url, "site_name": "Renew", "locale": "en"},
|
||||
"twitter": map[string]any{"card": "summary_large_image", "title": title, "description": "We price by the square metre.", "image": image, "image_alt": ""},
|
||||
"fallbacks": map[string]any{"title": "title", "description": "body", "image": "site", "canonical": "site"},
|
||||
"published_at": "2026-09-01T08:00:00Z", "modified_at": "2026-09-10T09:30:00Z",
|
||||
}
|
||||
for key, expected := range want {
|
||||
if !reflect.DeepEqual(object[key], expected) {
|
||||
t.Errorf("%s = %v, expected %v", key, object[key], expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The title chain and the template", func(t *testing.T) {
|
||||
cases := []struct{ seoTitle, template, title, source string }{
|
||||
{"", "%title% | %site%", seoOutArticleTitle + " | Renew", "title"},
|
||||
{"Bathroom costs", "%title% | %site%", "Bathroom costs | Renew", "seo_title"},
|
||||
{"Bathroom costs", "%site% - %title%", "Renew - Bathroom costs", "seo_title"},
|
||||
{"Bathroom costs", "%title%", "Bathroom costs", "seo_title"},
|
||||
{"Bathroom costs", "News: %title%", "News: Bathroom costs", "seo_title"},
|
||||
{"Bathroom costs", "", "Bathroom costs | Renew", "seo_title"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SEOTitle = c.seoTitle })
|
||||
e.changeSite(siteMainID, func(s *site.Site) { s.TitleTemplate = c.template })
|
||||
object := e.resolve(t, 12)
|
||||
if object["title"] != c.title || nested(object, "fallbacks")["title"] != c.source {
|
||||
t.Errorf("%+v: title %q source %v", c, object["title"], nested(object, "fallbacks")["title"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An empty site_name leaves nothing in place of %site% and the title is trimmed", func(t *testing.T) {
|
||||
cases := map[string]string{"%title% | %site%": "Bathroom costs |", "%site% %title%": "Bathroom costs", "%title%": "Bathroom costs"}
|
||||
for template, want := range cases {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SEOTitle = "Bathroom costs" })
|
||||
e.changeSite(siteMainID, func(s *site.Site) { s.SiteName = ""; s.TitleTemplate = template })
|
||||
if got := e.resolve(t, 12)["title"]; got != want {
|
||||
t.Errorf("%q: title %q, expected %q", template, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The description chain", func(t *testing.T) {
|
||||
cases := []struct{ seoDescription, excerpt, body, fallback, want, source string }{
|
||||
{"Own words.", "A teaser.", "<p>First one. Second.</p>", "Site.", "Own words.", "seo_description"},
|
||||
{"", "A teaser.", "<p>First one. Second.</p>", "Site.", "A teaser.", "excerpt"},
|
||||
{"", "", "<p>First one. Second.</p>", "Site.", "First one.", "body"},
|
||||
{"", "", "<p>No terminator here</p>", "Site.", "No terminator here", "body"},
|
||||
{"", "", "<p>Only sentence.</p>", "Site.", "Only sentence.", "body"},
|
||||
{"", "", "<script>var a = 1. 2;</script><style>p { x: y. }</style><script>b.</script><p>Real text. More.</p>", "Site.", "Real text.", "body"},
|
||||
{"", "", "<h2>Title</h2><p>What? Yes.</p>", "Site.", "Title What?", "body"},
|
||||
{"", "", "<p>Fish & chips are 3.5 euros. Cheap.</p>", "Site.", "Fish & chips are 3.5 euros.", "body"},
|
||||
{"", "", "", "Site.", "Site.", "site"},
|
||||
{"", "", "", "", "", "none"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SEODescription, a.Excerpt, a.Body = c.seoDescription, c.excerpt, c.body })
|
||||
e.changeSite(siteMainID, func(s *site.Site) { s.DefaultDescription = c.fallback })
|
||||
object := e.resolve(t, 12)
|
||||
if object["description"] != c.want || nested(object, "fallbacks")["description"] != c.source {
|
||||
t.Errorf("%+v: description %q source %v", c, object["description"], nested(object, "fallbacks")["description"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The image chain", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
social, cover *uint32
|
||||
alt string
|
||||
path, imgAlt string
|
||||
source string
|
||||
}{
|
||||
{idPointer(6), idPointer(5), "A field", "6.png", "", "social"},
|
||||
{nil, idPointer(5), "A field", "5.png", "A field", "cover"},
|
||||
{nil, nil, "", "7.png", "", "site"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SocialImageID, a.CoverImageID, a.CoverImageAlt = c.social, c.cover, c.alt })
|
||||
object := e.resolve(t, 12)
|
||||
image := nested(object, "image")
|
||||
if image["url"] != seoOutImageURL+c.path || image["alt"] != c.imgAlt || image["source"] != c.source ||
|
||||
nested(object, "og")["image"] != image["url"] || nested(object, "twitter")["image"] != image["url"] || nested(object, "fallbacks")["image"] != c.source {
|
||||
t.Errorf("%+v: %v", c, object)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No image anywhere", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.changeSite(siteMainID, func(s *site.Site) { s.DefaultSocialImageID = nil })
|
||||
object := e.resolve(t, 12)
|
||||
if !reflect.DeepEqual(object["image"], map[string]any{"url": "", "alt": "", "source": "none"}) || nested(object, "og")["image"] != "" ||
|
||||
nested(object, "twitter")["image"] != "" || nested(object, "twitter")["card"] != "summary" || nested(object, "fallbacks")["image"] != "none" {
|
||||
t.Errorf("object = %v", object)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A dangling image id falls through to the next source", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SocialImageID = idPointer(999) })
|
||||
if source := nested(e.resolve(t, 12), "image")["source"]; source != "site" {
|
||||
t.Errorf("source = %v", source)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Canonical and robots", func(t *testing.T) {
|
||||
own := seoOutBase + "/" + seoOutArticleSlug
|
||||
cases := []struct {
|
||||
canonical string
|
||||
noindex bool
|
||||
robots string
|
||||
resolved string
|
||||
source string
|
||||
result string
|
||||
}{
|
||||
{"", false, "index", own, "site", "index, follow"},
|
||||
{"", false, "noindex", own, "site", "noindex, nofollow"},
|
||||
{"https://other.example/a", false, "index", "https://other.example/a", "canonical_url", "index, follow"},
|
||||
{"", true, "index", own, "site", "noindex, nofollow"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.CanonicalURL, a.Noindex = c.canonical, c.noindex })
|
||||
e.changeSite(siteMainID, func(s *site.Site) { s.DefaultRobots = c.robots })
|
||||
object := e.resolve(t, 12)
|
||||
if object["canonical_url"] != c.resolved || nested(object, "og")["url"] != c.resolved || nested(object, "fallbacks")["canonical"] != c.source || object["robots"] != c.result {
|
||||
t.Errorf("%+v: %v", c, object)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Social overrides apply to og and twitter only", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) {
|
||||
a.SocialTitle, a.SocialDescription, a.SEOTitle, a.SEODescription = "Shared title", "Shared text", "Meta title", "Meta text"
|
||||
})
|
||||
object := e.resolve(t, 12)
|
||||
og, twitter := nested(object, "og"), nested(object, "twitter")
|
||||
if object["title"] != "Meta title | Renew" || object["description"] != "Meta text" ||
|
||||
og["title"] != "Shared title" || twitter["title"] != "Shared title" || og["description"] != "Shared text" || twitter["description"] != "Shared text" {
|
||||
t.Errorf("object = %v", object)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Social fields fall back to the resolved values independently", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SocialTitle = "Shared title" })
|
||||
object := e.resolve(t, 12)
|
||||
if nested(object, "og")["title"] != "Shared title" || nested(object, "og")["description"] != object["description"] {
|
||||
t.Errorf("og = %v", nested(object, "og"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Authors carry the profile URL and only the social links in same_as", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.credit(seoOutAuthorMarta, "Marta Alves", "https://marta.example", author.SocialLink{Type: "github", URL: "https://github.com/marta"}, author.SocialLink{Type: "x", URL: "https://x.com/marta"})
|
||||
e.credit(seoOutAuthorZoe, "Zoe Lambert", "")
|
||||
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "")
|
||||
var decoded struct {
|
||||
Authors []map[string]any `json:"authors"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []map[string]any{
|
||||
{"name": "Marta Alves", "url": "https://marta.example", "same_as": []any{"https://github.com/marta", "https://x.com/marta"}},
|
||||
{"name": "Zoe Lambert", "url": "", "same_as": []any{}},
|
||||
}
|
||||
if !reflect.DeepEqual(decoded.Authors, want) {
|
||||
t.Errorf("authors = %v", decoded.Authors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An article without authors has an empty authors array", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
object := e.resolve(t, 12)
|
||||
if !reflect.DeepEqual(object["authors"], []any{}) {
|
||||
t.Errorf("authors = %v", object["authors"])
|
||||
}
|
||||
if _, present := nested(object, "json_ld")["author"]; present {
|
||||
t.Error("json_ld has an author key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json_ld is a ready Article object", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.credit(seoOutAuthorMarta, "Marta Alves", "https://marta.example", author.SocialLink{Type: "github", URL: "https://github.com/marta"}, author.SocialLink{Type: "x", URL: "https://x.com/marta"})
|
||||
e.change(12, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = idPointer(5), "A field" })
|
||||
want := `{"@context": "https://schema.org", "@type": "Article", "headline": "How we price a bathroom renovation", "description": "We price by the square metre.", "image": ["https://www.renew.example/static/uploadedImages/5.png"], "author": [{"@type": "Person", "name": "Marta Alves", "url": "https://marta.example", "sameAs": ["https://github.com/marta", "https://x.com/marta"]}], "datePublished": "2026-09-01T08:00:00Z", "dateModified": "2026-09-10T09:30:00Z", "mainEntityOfPage": {"@type": "WebPage", "@id": "https://www.renew.example/how-we-price-a-bathroom-renovation"}}`
|
||||
var expected any
|
||||
if err := json.Unmarshal([]byte(want), &expected); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.resolve(t, 12)["json_ld"]; !reflect.DeepEqual(got, expected) {
|
||||
t.Errorf("json_ld = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The headline uses the meta title without the site template", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SEOTitle = "Bathroom costs" })
|
||||
object := e.resolve(t, 12)
|
||||
if nested(object, "json_ld")["headline"] != "Bathroom costs" || object["title"] != "Bathroom costs | Renew" {
|
||||
t.Errorf("object = %v", object)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The object never carries the body", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "")
|
||||
if strings.Contains(rec.Body.String(), "<p>") || strings.Contains(rec.Body.String(), "Tiles cost extra") {
|
||||
t.Errorf("body leaked: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No format parameter is honoured", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
bare := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "").Body.String()
|
||||
for _, query := range []string{"?format=html", "?format=json", "?format=pdf"} {
|
||||
rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo"+query, "")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != bare {
|
||||
t.Errorf("%s: %d", query, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Another site uses its own base URL, template and robots", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.SiteID = seoOutJournalID })
|
||||
object := e.resolve(t, 12)
|
||||
if !strings.HasPrefix(object["canonical_url"].(string), "https://journal.renew.example/") || object["robots"] != "noindex, nofollow" || object["title"] != seoOutArticleTitle {
|
||||
t.Errorf("object = %v", object)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSeoOutputResolverVisibility(t *testing.T) {
|
||||
t.Run("An online article is public", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
if rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A draft is visible to verified callers only", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
who string
|
||||
status int
|
||||
}{
|
||||
{"owner", 200}, {"admin", 200}, {"editor", 200}, {"key", 200}, {"", 404}, {"unverified", 404}, {"malformed", 404},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.who, func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
rec := e.call(t, c.who, http.MethodGet, "/v1/article/13/seo", "")
|
||||
if rec.Code != c.status {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c.status == 404 {
|
||||
expectStatusAndText(t, rec, 404, apperr.ArticleNotFoundErrorMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A draft has no publication date", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
rec := e.call(t, "owner", http.MethodGet, "/v1/article/13/seo", "")
|
||||
object := decodeSeoObject(t, rec)
|
||||
if object["published_at"] != nil || object["modified_at"] != "2026-09-10T09:30:00Z" {
|
||||
t.Errorf("object = %v", object)
|
||||
}
|
||||
if _, present := nested(object, "json_ld")["datePublished"]; present {
|
||||
t.Error("json_ld has datePublished")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unpublishing hides the object from the public again", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
if rec := e.call(t, "owner", http.MethodPatch, "/v1/article/12/unpublish", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("unpublish %d", rec.Code)
|
||||
}
|
||||
expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/article/12/seo", ""), 404, apperr.ArticleNotFoundErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("A missing article is not found", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/999/seo", ""), 404, apperr.ArticleNotFoundErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("A malformed id is refused", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
expectStatusAndText(t, e.call(t, "owner", http.MethodGet, "/v1/article/abc/seo", ""), 400, apperr.IdUint32ErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("The read is idempotent and writes nothing", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
before := e.stored(12)
|
||||
first := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "").Body.String()
|
||||
second := e.call(t, "", http.MethodGet, "/v1/article/12/seo", "").Body.String()
|
||||
if first != second || e.counted.writes != 0 || !reflect.DeepEqual(e.stored(12), before) {
|
||||
t.Errorf("writes %d", e.counted.writes)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A repository failure answers 500", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.articles.fail.failNext("Get")
|
||||
if rec := e.call(t, "", http.MethodGet, "/v1/article/12/seo", ""); rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func (e *seoOutEnv) sitemap(t *testing.T, who string, query string) (*httptest.ResponseRecorder, []map[string]any) {
|
||||
t.Helper()
|
||||
rec := e.call(t, who, http.MethodGet, "/v1/site/renew/sitemap"+query, "")
|
||||
var items []map[string]any
|
||||
if rec.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return rec, items
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) seedOnline(count int) {
|
||||
e.cleanArticles()
|
||||
for id := 1; id <= count; id++ {
|
||||
e.articles.articles[uint32(id)] = article.Article{
|
||||
ID: uint32(id), Title: "Article " + strconv.Itoa(id), IsOnline: true, SiteID: siteMainID, Slug: "slug-" + strconv.Itoa(id),
|
||||
CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeoOutputSitemap(t *testing.T) {
|
||||
t.Run("The sitemap lists the online articles of one site", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.articles.articles[16] = article.Article{ID: 16, Title: "Sixteen", IsOnline: true, SiteID: siteMainID, Slug: "sixteen", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified}
|
||||
e.articles.articles[20] = article.Article{ID: 20, Title: "Twenty", IsOnline: true, SiteID: seoOutJournalID, Slug: "twenty", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified}
|
||||
rec, items := e.sitemap(t, "", "")
|
||||
want := []map[string]any{
|
||||
{"url": seoOutBase + "/" + seoOutArticleSlug, "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
|
||||
{"url": seoOutBase + "/sixteen", "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
|
||||
}
|
||||
if rec.Code != http.StatusOK || !reflect.DeepEqual(items, want) || rec.Header().Get("X-Total-Count") != "2" {
|
||||
t.Errorf("status %d total %q items %v", rec.Code, rec.Header().Get("X-Total-Count"), items)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Noindex articles are included with the flag", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.Noindex = true })
|
||||
_, items := e.sitemap(t, "", "")
|
||||
if len(items) != 1 || items[0]["noindex"] != true {
|
||||
t.Errorf("items = %v", items)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The url uses the slug and not the canonical override", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.CanonicalURL = "https://other.example/a" })
|
||||
_, items := e.sitemap(t, "", "")
|
||||
if items[0]["url"] != seoOutBase+"/"+seoOutArticleSlug {
|
||||
t.Errorf("items = %v", items)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Drafts are never listed", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
rec, items := e.sitemap(t, "owner", "")
|
||||
if rec.Header().Get("X-Total-Count") != "1" || len(items) != 1 {
|
||||
t.Errorf("items = %v", items)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unpublished article leaves the sitemap", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.call(t, "owner", http.MethodPatch, "/v1/article/12/unpublish", "")
|
||||
rec, items := e.sitemap(t, "", "")
|
||||
if rec.Header().Get("X-Total-Count") != "0" || len(items) != 0 {
|
||||
t.Errorf("items = %v", items)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An empty site answers an empty array", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.change(12, func(a *article.Article) { a.IsOnline = false })
|
||||
rec, _ := e.sitemap(t, "", "")
|
||||
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" {
|
||||
t.Errorf("%d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Each item has exactly the documented keys", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
_, items := e.sitemap(t, "", "")
|
||||
if sortedKeys(items[0]) != "modified_at,noindex,url" {
|
||||
t.Errorf("keys = %s", sortedKeys(items[0]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Paging and the total header", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
count int
|
||||
}{{"", 100}, {"?size=25", 25}, {"?page=3", 50}, {"?page=2&size=100", 100}, {"?size=500", 250}, {"?page=99&size=25", 0}}
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedOnline(250)
|
||||
for _, c := range cases {
|
||||
rec, items := e.sitemap(t, "", c.query)
|
||||
if len(items) != c.count || rec.Header().Get("X-Total-Count") != "250" {
|
||||
t.Errorf("%q: %d items, total %q", c.query, len(items), rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Pages partition the sitemap in a stable order", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedOnline(60)
|
||||
seen := []string{}
|
||||
for page, want := range map[int]int{1: 25, 2: 25, 3: 10} {
|
||||
_, items := e.sitemap(t, "", "?size=25&page="+strconv.Itoa(page))
|
||||
if len(items) != want {
|
||||
t.Errorf("page %d holds %d", page, len(items))
|
||||
}
|
||||
for index, item := range items {
|
||||
id := (page-1)*25 + index + 1
|
||||
if item["url"] != seoOutBase+"/slug-"+strconv.Itoa(id) {
|
||||
t.Errorf("page %d item %d = %v", page, index, item["url"])
|
||||
}
|
||||
seen = append(seen, item["url"].(string))
|
||||
}
|
||||
}
|
||||
if len(seen) != 60 {
|
||||
t.Errorf("seen %d", len(seen))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid paging answers 400", func(t *testing.T) {
|
||||
for _, query := range []string{"page=0", "page=-1", "page=abc", "page=", "size=0", "size=10", "size=1000", "size=abc"} {
|
||||
e := newSeoOutEnv(t)
|
||||
before := e.counted.readCalls()
|
||||
expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/site/renew/sitemap?"+query, ""), 400, apperr.BodyErrorMessage)
|
||||
if e.counted.readCalls() != before {
|
||||
t.Errorf("%s: the repository was called", query)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown key is not found", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/site/nope/sitemap", ""), 404, apperr.SiteNotFoundErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("The key is not an id", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
if rec := e.call(t, "", http.MethodGet, "/v1/site/1/sitemap", ""); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The route does not disturb the owner-only site routes", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
expectStatusAndText(t, e.call(t, "admin", http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`), 403, apperr.OwnerRequiredErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("The browser can read the total header", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
settings := e.settings
|
||||
settings.CorsAllowedOrigins = []string{"https://app.example"}
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/site/renew/sitemap", nil)
|
||||
req.Header.Set("Origin", "https://app.example")
|
||||
rec := httptest.NewRecorder()
|
||||
api.InitRoutes(e.container, settings).ServeHTTP(rec, req)
|
||||
exposed := rec.Header().Get("Access-Control-Expose-Headers")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(exposed, "X-Total-Count") || !strings.Contains(exposed, "Link") {
|
||||
t.Errorf("status %d exposed %q", rec.Code, exposed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A repository failure answers 500", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.articles.fail.failNext("FindPage")
|
||||
if rec := e.call(t, "", http.MethodGet, "/v1/site/renew/sitemap", ""); rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"RenewCMS/api"
|
||||
)
|
||||
|
||||
const (
|
||||
seoOutArticleID = 12
|
||||
seoOutDraftID = 13
|
||||
seoOutArticleTitle = "How we price a bathroom renovation"
|
||||
seoOutArticleSlug = "how-we-price-a-bathroom-renovation"
|
||||
seoOutBody = "<p>We price by the square metre. Tiles cost extra.</p>"
|
||||
seoOutBase = "https://www.renew.example"
|
||||
seoOutSiteImage = 7
|
||||
seoOutJournalID = 2
|
||||
seoOutAuthorMarta = 2
|
||||
seoOutAuthorZoe = 3
|
||||
)
|
||||
|
||||
var (
|
||||
seoOutCreated = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
||||
seoOutModified = time.Date(2026, 9, 10, 9, 30, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
type mockSeoOutArticleRepo struct {
|
||||
*mockArticleRepo
|
||||
reads map[string]int
|
||||
writes int
|
||||
}
|
||||
|
||||
func newMockSeoOutArticleRepo(inner *mockArticleRepo) *mockSeoOutArticleRepo {
|
||||
return &mockSeoOutArticleRepo{mockArticleRepo: inner, reads: map[string]int{}}
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) readCalls() int {
|
||||
total := 0
|
||||
for _, count := range m.reads {
|
||||
total += count
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) Get(id uint32) (article.Article, error) {
|
||||
m.reads["Get"]++
|
||||
return m.mockArticleRepo.Get(id)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) FindPage(filters article.Filters) (article.PageResult, error) {
|
||||
m.reads["FindPage"]++
|
||||
return m.mockArticleRepo.FindPage(filters)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, error) {
|
||||
m.reads["CountByVisibility"]++
|
||||
return m.mockArticleRepo.CountByVisibility(onlineOnly)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
m.reads["SlugTaken"]++
|
||||
return m.mockArticleRepo.SlugTaken(siteID, slug, exceptID)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) Create(a article.Article) (article.Article, error) {
|
||||
m.writes++
|
||||
return m.mockArticleRepo.Create(a)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
|
||||
m.writes++
|
||||
return m.mockArticleRepo.UpdateBody(id, body)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
|
||||
m.writes++
|
||||
return m.mockArticleRepo.Update(id, update)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
|
||||
m.writes++
|
||||
return m.mockArticleRepo.UpdateIsOnline(id, isOnline)
|
||||
}
|
||||
|
||||
func (m *mockSeoOutArticleRepo) Delete(id uint32) error {
|
||||
m.writes++
|
||||
return m.mockArticleRepo.Delete(id)
|
||||
}
|
||||
|
||||
var _ article.Repository = &mockSeoOutArticleRepo{}
|
||||
|
||||
type seoOutEnv struct {
|
||||
*seoEnv
|
||||
counted *mockSeoOutArticleRepo
|
||||
}
|
||||
|
||||
func newSeoOutEnv(t *testing.T) *seoOutEnv {
|
||||
t.Helper()
|
||||
e := newSeoEnv(t)
|
||||
counted := newMockSeoOutArticleRepo(e.articles)
|
||||
locale := "en"
|
||||
renew := e.sites.sites[siteMainID]
|
||||
renew.Key = "renew"
|
||||
renew.BaseURL = seoOutBase
|
||||
renew.SiteName = "Renew"
|
||||
renew.TitleTemplate = "%title% | %site%"
|
||||
renew.DefaultDescription = "Renovation notes from the Renew team."
|
||||
renew.DefaultRobots = "index"
|
||||
renew.Locale = &locale
|
||||
renew.DefaultSocialImageID = idPointer(seoOutSiteImage)
|
||||
e.sites.sites[siteMainID] = renew
|
||||
journal := e.sites.sites[seoOutJournalID]
|
||||
journal.Key = "journal"
|
||||
journal.BaseURL = "https://journal.renew.example"
|
||||
journal.TitleTemplate = "%title%"
|
||||
journal.DefaultRobots = "noindex"
|
||||
e.sites.sites[seoOutJournalID] = journal
|
||||
e.seedBanner(seoOutSiteImage, "site.png", "hash-site")
|
||||
c := e.container
|
||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images)
|
||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images)
|
||||
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites)
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
|
||||
e.router = router
|
||||
e.articles.articles[seoOutArticleID] = article.Article{
|
||||
ID: seoOutArticleID, Title: seoOutArticleTitle, Body: seoOutBody, IsOnline: true, SiteID: siteMainID, Slug: seoOutArticleSlug,
|
||||
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
|
||||
}
|
||||
e.articles.articles[seoOutDraftID] = article.Article{
|
||||
ID: seoOutDraftID, Title: "A draft about tiles", Body: seoOutBody, SiteID: siteMainID, Slug: "a-draft-about-tiles",
|
||||
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
|
||||
}
|
||||
e.articles.nextID = 100
|
||||
return &seoOutEnv{seoEnv: e, counted: counted}
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) change(id uint32, mutate func(*article.Article)) {
|
||||
found := e.articles.articles[id]
|
||||
mutate(&found)
|
||||
e.articles.articles[id] = found
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) changeSite(id uint32, mutate func(*site.Site)) {
|
||||
found := e.sites.sites[id]
|
||||
mutate(&found)
|
||||
e.sites.sites[id] = found
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) credit(id uint32, name string, website string, links ...author.SocialLink) {
|
||||
found := e.authors.seed(id, name, strings.ToLower(strings.ReplaceAll(name, " ", "-")))
|
||||
if website != "" {
|
||||
found.Website = &website
|
||||
}
|
||||
found.SocialLinks = append([]author.SocialLink{}, links...)
|
||||
e.authors.authors[id] = found
|
||||
e.articles.credits[seoOutArticleID] = append(e.articles.credits[seoOutArticleID], id)
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) call(t *testing.T, who string, method string, path string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
switch who {
|
||||
case "owner":
|
||||
req.AddCookie(e.cookie(t, apiKeyOwnerId, 0))
|
||||
case "admin":
|
||||
req.AddCookie(e.cookie(t, apiKeyPatId, 0))
|
||||
case "editor":
|
||||
req.AddCookie(e.cookie(t, seoEditorID, 0))
|
||||
case "unverified":
|
||||
req.AddCookie(e.cookie(t, apiKeyQuinnId, 0))
|
||||
case "malformed":
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: "not-a-token"})
|
||||
case "key":
|
||||
raw, _ := e.issueKey(apiKeyDevId, "seo")
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) resolve(t *testing.T, id int) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.call(t, "", http.MethodGet, "/v1/article/"+strconv.Itoa(id)+"/seo", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("resolve %d: %d %s", id, rec.Code, rec.Body.String())
|
||||
}
|
||||
return decodeSeoObject(t, rec)
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) check(t *testing.T, body string) map[string]any {
|
||||
t.Helper()
|
||||
rec := e.call(t, "owner", http.MethodPost, "/v1/article/12/seo/check", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("check %s: %d %s", body, rec.Code, rec.Body.String())
|
||||
}
|
||||
return decodeSeoObject(t, rec)
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) checkCodes(t *testing.T, body string) []string {
|
||||
t.Helper()
|
||||
return suggestionCodes(e.check(t, body))
|
||||
}
|
||||
|
||||
func suggestionCodes(checked map[string]any) []string {
|
||||
codes := []string{}
|
||||
for _, item := range checked["suggestions"].([]any) {
|
||||
codes = append(codes, item.(map[string]any)["code"].(string))
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func hasCode(codes []string, code string) bool {
|
||||
for _, found := range codes {
|
||||
if found == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sortedKeys(object map[string]any) string {
|
||||
keys := make([]string, 0, len(object))
|
||||
for key := range object {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
func nested(object map[string]any, key string) map[string]any {
|
||||
return object[key].(map[string]any)
|
||||
}
|
||||
|
||||
func textBody(runes int, extra string) string {
|
||||
return "<p>" + strings.Repeat("a", runes) + "</p>" + extra
|
||||
}
|
||||
|
||||
func jsonBody(t *testing.T, field string, value string) string {
|
||||
t.Helper()
|
||||
return `{"` + field + `": ` + jsonString(t, value) + `}`
|
||||
}
|
||||
|
||||
func listIDs(list map[string]any) []int {
|
||||
ids := []int{}
|
||||
for _, item := range list["items"].([]any) {
|
||||
ids = append(ids, int(item.(map[string]any)["id"].(float64)))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (e *seoOutEnv) changeSiteTemplate(template string) {
|
||||
e.changeSite(siteMainID, func(s *site.Site) { s.TitleTemplate = template })
|
||||
}
|
||||
Reference in new issue
Block a user