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.", "

First one. Second.

", "Site.", "Own words.", "seo_description"}, {"", "A teaser.", "

First one. Second.

", "Site.", "A teaser.", "excerpt"}, {"", "", "

First one. Second.

", "Site.", "First one.", "body"}, {"", "", "

No terminator here

", "Site.", "No terminator here", "body"}, {"", "", "

Only sentence.

", "Site.", "Only sentence.", "body"}, {"", "", "

Real text. More.

", "Site.", "Real text.", "body"}, {"", "", "

Title

What? Yes.

", "Site.", "Title What?", "body"}, {"", "", "

Fish & chips are 3.5 euros. Cheap.

", "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(), "

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