feat: SEO backend output (2/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 21:48:55 +02:00
1 parent 0eb46b0d74
commit eb9876fde3
32 files changed
+3859 -65

No files matched your search

+5
View File
@@ -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)
}
+431
View File
@@ -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)
}
})
}
+84
View File
@@ -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)
}
}
+40
View File
@@ -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)
}
}
}
}
+234
View File
@@ -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)
}
})
}
+369
View File
@@ -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 &amp; 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)
}
})
}
+191
View File
@@ -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)
}
})
}
+271
View File
@@ -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 })
}