370 lines
16 KiB
Go
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 & 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)
|
|
}
|
|
})
|
|
}
|