Files
RenewCMS/test/seo_output_resolver_http_test.go
T

370 lines
16 KiB
Go

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)
}
})
}