feat: public API, SEO and existing surfaces

This commit is contained in:
Florian Sylvain committed 2026-11-03 03:58:10 +01:00
1 parent 45fbbc33b1
commit c390fcfe8d
103 files changed
+4476 -231

No files matched your search

+832
View File
@@ -0,0 +1,832 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
const (
locJournalID = 11
locAtelierID = 12
locNotesID = 13
locPattern = "https://journal.example.com/{lang}/{slug}"
locEnSlug = "how-we-price-a-bathroom-renovation"
locFrSlug = "comment-nous-chiffrons-une-renovation"
locDeSlug = "so-kalkulieren-wir-eine-badsanierung"
locFrBase = "/v1/public/site/journal/lang/fr/article/" + locFrSlug
)
type locPublicEnv struct {
*seoEnv
store *fakeLocalizationStore
}
func newLocPublicEnv(t *testing.T) *locPublicEnv {
t.Helper()
e := newSeoEnv(t)
for id := range e.sites.sites {
delete(e.sites.sites, id)
}
e.articles.articles = map[uint32]article.Article{}
store := newFakeLocalizationStore(e.articles)
renderer := &fakeBodyRenderer{}
c := e.container
c.GetArticleUseCase = useCases.NewGetArticleUseCase(e.articles, renderer)
c.DescribeArticleUseCase = useCases.NewDescribeArticleUseCase(e.articles, store)
c.ResolvePublicArticleUseCase = useCases.NewResolvePublicArticleUseCase(e.articles, e.sites, store, renderer)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(e.articles, e.sites, e.images, store)
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(e.articles, e.sites, store)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
e.router = router
pattern := locPattern
locale := "en-GB"
journal := e.sites.seed(locJournalID, "Journal", "journal", true)
journal.BaseURL = "https://www.journal.example"
journal.Locale = &locale
journal.SiteName = "Journal"
journal.TitleTemplate = "%title% | %site%"
journal.DefaultDescription = "Renovation notes."
journal.PublicURLPattern = &pattern
e.sites.sites[locJournalID] = journal
e.sites.seed(locAtelierID, "Atelier", "atelier", false)
e.sites.seed(locNotesID, "Notes", "notes", false)
frSocial := "fr_FR"
store.rows[locJournalID] = []localization.Row{
{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
{Tag: "fr", Enabled: true, Confirmed: true, SocialLocale: &frSocial},
{Tag: "de", Enabled: true, Confirmed: true},
{Tag: "ar", Enabled: true, Confirmed: true},
{Tag: "es", Confirmed: true},
{Tag: "pt-BR", Enabled: true, Confirmed: true},
{Tag: "fr-CA", Enabled: true, Confirmed: true},
}
store.rows[locAtelierID] = []localization.Row{{Tag: "fr", IsDefault: true, Enabled: true, Confirmed: true}, {Tag: "en", Enabled: true, Confirmed: true}}
e.version(10, "en", locEnSlug, "How we price a bathroom renovation", 5, true)
e.version(12, "fr", locFrSlug, "Comment nous chiffrons une rénovation", 5, true)
e.version(11, "de", locDeSlug, "So kalkulieren wir eine Badsanierung", 5, true)
e.version(13, "ar", "takalif", "Takalif", 5, false)
e.version(14, "es", "cuanto-cuesta", "Cuánto cuesta", 5, true)
e.version(20, "en", "spring", "Spring", 6, true)
e.version(21, "en", "winter", "Winter", 7, false)
e.version(30, "", "legacy-note", "Legacy note", 30, true)
e.version(40, "pt-BR", "reforma", "Reforma", 8, true)
e.version(41, "fr-CA", "renovation", "Rénovation", 9, true)
e.stored(30)
return &locPublicEnv{seoEnv: e, store: store}
}
func (e *seoEnv) version(id uint32, lang string, slug string, title string, story uint32, online bool) {
owner := uint32(locJournalID)
e.articles.articles[id] = article.Article{
ID: id, Title: title, Body: "<p>" + title + ".</p>", IsOnline: online, SiteID: owner, Lang: lang, StoryID: story, Slug: slug,
SEODismissed: []string{}, CreatedAt: seoOutCreated, UpdatedAt: seoOutModified,
}
}
func (e *locPublicEnv) change(id uint32, mutate func(*article.Article)) {
found := e.articles.articles[id]
mutate(&found)
e.articles.articles[id] = found
}
func (e *locPublicEnv) changeSite(id uint32, mutate func(*site.Site)) {
found := e.sites.sites[id]
mutate(&found)
e.sites.sites[id] = found
}
func (e *locPublicEnv) get(t *testing.T, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
func (e *locPublicEnv) json(t *testing.T, path string) map[string]any {
t.Helper()
rec := e.get(t, path, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d %q", path, rec.Code, rec.Body.String())
}
var decoded map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return decoded
}
func alternateIDs(object map[string]any) []float64 {
ids := []float64{}
for _, entry := range object["alternates"].([]any) {
ids = append(ids, entry.(map[string]any)["id"].(float64))
}
return ids
}
func TestLocalizationPublicExactLookup(t *testing.T) {
t.Run("A published version is returned with its alternates", func(t *testing.T) {
e := newLocPublicEnv(t)
body := e.json(t, locFrBase)
direct := e.json(t, "/v1/article/12")
alternates := body["alternates"]
delete(body, "alternates")
if !reflect.DeepEqual(body, direct) || body["id"] != float64(12) || body["lang"] != "fr" || body["story_id"] != float64(5) || body["is_online"] != true {
t.Errorf("body differs from GET /v1/article/12: %v", body)
}
if !reflect.DeepEqual(alternates, []any{
map[string]any{"id": float64(10), "lang": "en", "story_id": float64(5), "slug": locEnSlug, "url": "https://journal.example.com/en/" + locEnSlug},
map[string]any{"id": float64(11), "lang": "de", "story_id": float64(5), "slug": locDeSlug, "url": "https://journal.example.com/de/" + locDeSlug},
}) {
t.Errorf("alternates = %v", alternates)
}
})
t.Run("Alternates leave out the served version, drafts and disabled languages", func(t *testing.T) {
e := newLocPublicEnv(t)
rec := e.get(t, "/v1/public/site/journal/lang/en/article/"+locEnSlug, nil)
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if ids := alternateIDs(body); !reflect.DeepEqual(ids, []float64{11, 12}) {
t.Errorf("alternates = %v", ids)
}
if strings.Contains(rec.Body.String(), "takalif") || strings.Contains(rec.Body.String(), "cuanto-cuesta") {
t.Errorf("a hidden sibling is named: %s", rec.Body.String())
}
})
t.Run("A disabled language stays readable by exact lookup", func(t *testing.T) {
e := newLocPublicEnv(t)
body := e.json(t, "/v1/public/site/journal/lang/es/article/cuanto-cuesta")
if body["lang"] != "es" || !reflect.DeepEqual(alternateIDs(body), []float64{10, 11, 12}) {
t.Errorf("body = %v", body)
}
})
t.Run("A story with one version has an empty alternates array", func(t *testing.T) {
e := newLocPublicEnv(t)
rec := e.get(t, "/v1/public/site/journal/lang/en/article/spring", nil)
var body map[string]json.RawMessage
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if rec.Code != 200 || string(body["alternates"]) != "[]" {
t.Errorf("%d %s", rec.Code, rec.Body.String())
}
})
t.Run("The lookup matches site, language and slug only", func(t *testing.T) {
cases := []struct {
path string
status int
body string
}{
{"journal/lang/de/article/" + locFrSlug, 404, "article_not_found"},
{"journal/lang/fr/article/" + locEnSlug, 404, "article_not_found"},
{"journal/lang/ar/article/takalif", 404, "article_not_found"},
{"journal/lang/en/article/winter", 404, "article_not_found"},
{"journal/lang/en/article/nope", 404, "article_not_found"},
{"journal/lang/FR/article/" + locFrSlug, 200, ""},
{"journal/lang/pt-br/article/spring", 404, "article_not_found"},
{"journal/lang/xx/article/spring", 404, "article_not_found"},
{"journal/lang/e/article/spring", 400, "invalid_language"},
{"journal/lang/pt_BR/article/spring", 400, "invalid_language"},
{"nowhere/lang/fr/article/" + locFrSlug, 404, "site_not_found"},
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
e := newLocPublicEnv(t)
rec := e.get(t, "/v1/public/site/"+c.path, nil)
if rec.Code != c.status || (c.body != "" && strings.TrimSpace(rec.Body.String()) != c.body) {
t.Errorf("%d %q", rec.Code, rec.Body.String())
}
})
}
})
t.Run("The same slug in another site or language is a different page", func(t *testing.T) {
e := newLocPublicEnv(t)
e.articles.articles[50] = article.Article{ID: 50, Title: "Atelier", IsOnline: true, SiteID: locAtelierID, Lang: "fr", StoryID: 50, Slug: locFrSlug, SEODismissed: []string{}}
atelier := e.json(t, "/v1/public/site/atelier/lang/fr/article/"+locFrSlug)
journal := e.json(t, locFrBase)
if atelier["site_id"] != float64(locAtelierID) || journal["id"] != float64(12) {
t.Errorf("atelier %v journal %v", atelier["site_id"], journal["id"])
}
})
t.Run("An unassigned article has no language route", func(t *testing.T) {
e := newLocPublicEnv(t)
rec := e.get(t, "/v1/public/site/journal/lang/en/article/legacy-note", nil)
expectStatusAndText(t, rec, 404, "article_not_found")
if direct := e.get(t, "/v1/article/30", nil); direct.Code != 200 {
t.Errorf("direct read = %d", direct.Code)
}
})
t.Run("A draft never answers, even to its owner", func(t *testing.T) {
e := newLocPublicEnv(t)
rec := e.get(t, "/v1/public/site/journal/lang/en/article/winter", e.cookie(t, apiKeyOwnerId, 0))
expectStatusAndText(t, rec, 404, "article_not_found")
})
t.Run("Unpublishing removes the version at once", func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(12, func(a *article.Article) { a.IsOnline = false })
rec := e.get(t, locFrBase, nil)
other := e.json(t, "/v1/public/site/journal/lang/en/article/"+locEnSlug)
expectStatusAndText(t, rec, 404, "article_not_found")
if !reflect.DeepEqual(alternateIDs(other), []float64{11}) {
t.Errorf("alternates = %v", alternateIDs(other))
}
})
t.Run("A changed slug breaks the old address and creates no redirect", func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(12, func(a *article.Article) { a.Slug = "nouveau-titre" })
old := e.get(t, locFrBase, nil)
renewed := e.get(t, "/v1/public/site/journal/lang/fr/article/nouveau-titre", nil)
expectStatusAndText(t, old, 404, "article_not_found")
if renewed.Code != 200 {
t.Errorf("new address = %d", renewed.Code)
}
})
t.Run("The body format parameter behaves as on GET /v1/article/{id}", func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(20, func(a *article.Article) { a.Body = "[]" })
body := e.json(t, "/v1/public/site/journal/lang/en/article/spring?format=json")
bad := e.get(t, "/v1/public/site/journal/lang/en/article/spring?format=pdf", nil)
expectStatusAndText(t, bad, 400, apperr.ArticleBodyFormatErrorMessage)
if body["body_format"] != "json" {
t.Errorf("body_format = %v", body["body_format"])
}
})
t.Run("A repository failure answers 500", func(t *testing.T) {
e := newLocPublicEnv(t)
e.articles.fail.failNext("GetByLangSlug")
rec := e.get(t, "/v1/public/site/journal/lang/en/article/spring", nil)
expectStatusAndText(t, rec, 500, "Internal server error")
})
}
func TestLocalizationPublicStoryResolution(t *testing.T) {
story := "/v1/public/site/journal/story/5"
t.Run("The requested language is published", func(t *testing.T) {
e := newLocPublicEnv(t)
body := e.json(t, story+"?lang=fr")
if body["id"] != float64(12) || body["lang"] != "fr" || body["requested_lang"] != "fr" || body["fallback"] != false || !reflect.DeepEqual(alternateIDs(body), []float64{10, 11}) {
t.Errorf("body = %v", body)
}
})
t.Run("The requested language is missing and no fallback is asked", func(t *testing.T) {
e := newLocPublicEnv(t)
delete(e.articles.articles, 12)
expectStatusAndText(t, e.get(t, story+"?lang=fr", nil), 404, "language_not_available")
})
t.Run("The requested language is missing and the default is the fallback", func(t *testing.T) {
e := newLocPublicEnv(t)
delete(e.articles.articles, 12)
body := e.json(t, story+"?lang=fr&fallback=default")
if body["id"] != float64(10) || body["lang"] != "en" || body["requested_lang"] != "fr" || body["fallback"] != true {
t.Errorf("body = %v", body)
}
})
t.Run("The requested language is unpublished, not only missing", func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(12, func(a *article.Article) { a.IsOnline = false })
body := e.json(t, story+"?lang=fr&fallback=default")
if body["id"] != float64(10) || body["fallback"] != true {
t.Errorf("body = %v", body)
}
})
t.Run("Neither the requested language nor the default is published", func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(12, func(a *article.Article) { a.IsOnline = false })
e.change(10, func(a *article.Article) { a.IsOnline = false })
rec := e.get(t, story+"?lang=fr&fallback=default", nil)
expectStatusAndText(t, rec, 404, "language_not_available")
if strings.Contains(rec.Body.String(), "so-kalkulieren") {
t.Errorf("a third language was returned")
}
})
t.Run("The default language itself is requested and unpublished", func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(10, func(a *article.Article) { a.IsOnline = false })
expectStatusAndText(t, e.get(t, story+"?lang=en&fallback=default", nil), 404, "language_not_available")
})
t.Run("No language means the site default language", func(t *testing.T) {
e := newLocPublicEnv(t)
body := e.json(t, story)
if body["id"] != float64(10) || body["lang"] != "en" || body["requested_lang"] != "en" || body["fallback"] != false {
t.Errorf("body = %v", body)
}
})
t.Run("Changing the default changes only requests that omit the language", func(t *testing.T) {
e := newLocPublicEnv(t)
rows := e.store.rows[locJournalID]
for index := range rows {
rows[index].IsDefault = rows[index].Tag == "fr"
}
omitted := e.json(t, story)
if omitted["id"] != float64(12) || omitted["requested_lang"] != "fr" {
t.Errorf("omitted = %v", omitted)
}
if e.json(t, story+"?lang=en")["id"] != float64(10) || e.json(t, "/v1/public/site/journal/lang/en/article/"+locEnSlug)["id"] != float64(10) || e.json(t, "/v1/article/10")["id"] != float64(10) {
t.Errorf("explicit requests changed")
}
})
t.Run("A story of a site with no confirmed default cannot be resolved without a language", func(t *testing.T) {
e := newLocPublicEnv(t)
e.articles.articles[60] = article.Article{ID: 60, Title: "Note", IsOnline: true, SiteID: locNotesID, Lang: "en", StoryID: 60, Slug: "note", SEODismissed: []string{}}
e.store.rows[locNotesID] = []localization.Row{{Tag: "en", IsDefault: true, Enabled: true}}
expectStatusAndText(t, e.get(t, "/v1/public/site/notes/story/60", nil), 404, "language_not_available")
if rec := e.get(t, "/v1/public/site/notes/story/60?lang=en", nil); rec.Code != 200 {
t.Errorf("explicit lang = %d", rec.Code)
}
})
t.Run("A disabled language is served when asked explicitly", func(t *testing.T) {
e := newLocPublicEnv(t)
if body := e.json(t, story+"?lang=es"); body["lang"] != "es" {
t.Errorf("body = %v", body)
}
})
t.Run("Refused and unknown requests", func(t *testing.T) {
cases := []struct {
path string
status int
body string
}{
{"journal/story/6?lang=fr", 404, "language_not_available"},
{"journal/story/7?lang=en", 404, "language_not_available"},
{"journal/story/999?lang=en", 404, "language_not_available"},
{"journal/story/5?lang=fr&fallback=latest", 400, apperr.BodyErrorMessage},
{"journal/story/5?lang=fr&fallback=", 400, apperr.BodyErrorMessage},
{"journal/story/5?lang=english", 400, "invalid_language"},
{"journal/story/abc?lang=en", 400, apperr.IdUint32ErrorMessage},
{"nowhere/story/5?lang=en", 404, "site_not_found"},
{"atelier/story/5?lang=en", 404, "language_not_available"},
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
e := newLocPublicEnv(t)
expectStatusAndText(t, e.get(t, "/v1/public/site/"+c.path, nil), c.status, c.body)
})
}
})
t.Run("The response carries no more than a direct read would", func(t *testing.T) {
e := newLocPublicEnv(t)
body := e.json(t, story+"?lang=fr")
exact := e.json(t, locFrBase)
delete(body, "requested_lang")
delete(body, "fallback")
if sortedKeys(body) != sortedKeys(exact) {
t.Errorf("keys %s vs %s", sortedKeys(body), sortedKeys(exact))
}
if _, leaked := body["created_by"]; leaked {
t.Errorf("created_by is present")
}
})
}
func TestLocalizationSEOOutput(t *testing.T) {
t.Run("The shape grows by additive keys", func(t *testing.T) {
e := newLocPublicEnv(t)
body := e.json(t, "/v1/article/12/seo")
if body["lang"] != "fr" || !reflect.DeepEqual(alternateIDs(body), []float64{10, 11}) {
t.Errorf("lang/alternates = %v %v", body["lang"], body["alternates"])
}
if _, ok := nested(body, "og")["locale_alternates"]; !ok {
t.Errorf("og.locale_alternates missing")
}
if _, ok := nested(body, "fallbacks")["site_text_skipped"]; !ok {
t.Errorf("fallbacks.site_text_skipped missing")
}
})
t.Run("An unassigned article keeps the legacy values", func(t *testing.T) {
e := newLocPublicEnv(t)
body := e.json(t, "/v1/article/30/seo")
og := nested(body, "og")
if body["lang"] != "" || len(body["alternates"].([]any)) != 0 || len(og["locale_alternates"].([]any)) != 0 || body["canonical_url"] != "https://www.journal.example/legacy-note" || og["locale"] != "en-GB" {
t.Errorf("body = %v", body)
}
})
t.Run("The canonical is the localized page unless overridden", func(t *testing.T) {
cases := []struct {
pattern *string
id uint32
override string
want string
source string
}{
{ptrText(locPattern), 12, "", "https://journal.example.com/fr/" + locFrSlug, "pattern"},
{ptrText("https://journal.example.com/{lang}/articles/{slug}"), 11, "", "https://journal.example.com/de/articles/" + locDeSlug, "pattern"},
{ptrText(locPattern), 12, "https://other.example/a", "https://other.example/a", "canonical_url"},
{nil, 12, "", "https://www.journal.example/" + locFrSlug, "site"},
{ptrText("https://journal.example.com/{slug}"), 12, "", "https://journal.example.com/" + locFrSlug, "pattern"},
{ptrText(locPattern), 40, "", "https://journal.example.com/pt-BR/reforma", "pattern"},
}
for _, c := range cases {
t.Run(c.want, func(t *testing.T) {
e := newLocPublicEnv(t)
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = c.pattern })
e.change(c.id, func(a *article.Article) { a.CanonicalURL = c.override })
body := e.json(t, "/v1/article/"+uintText(c.id)+"/seo")
if body["canonical_url"] != c.want || nested(body, "og")["url"] != c.want || nested(body, "fallbacks")["canonical"] != c.source || nested(nested(body, "json_ld"), "mainEntityOfPage")["@id"] != c.want {
t.Errorf("body = %v", body)
}
})
}
})
t.Run("Saving the pattern now changes canonical output", func(t *testing.T) {
e := newLocPublicEnv(t)
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = nil })
e.change(11, func(a *article.Article) { a.CanonicalURL = "https://other.example/de" })
before := e.json(t, "/v1/article/12/seo")["canonical_url"]
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = ptrText(locPattern) })
after := e.json(t, "/v1/article/12/seo")["canonical_url"]
if before != "https://www.journal.example/"+locFrSlug || after != "https://journal.example.com/fr/"+locFrSlug || e.stored(11).CanonicalURL != "https://other.example/de" {
t.Errorf("before %v after %v", before, after)
}
})
t.Run("og:locale is derived in a fixed order and never invented", func(t *testing.T) {
cases := []struct {
siteLocale *string
lang string
explicit *string
id uint32
want string
}{
{ptrText("en-GB"), "fr", ptrText("fr_FR"), 12, "fr_FR"},
{ptrText("en-GB"), "fr", ptrText("fr_CA"), 12, "fr_CA"},
{ptrText("en-GB"), "pt-BR", nil, 40, "pt_BR"},
{ptrText("en-GB"), "en", nil, 10, "en_GB"},
{ptrText("fr-CA"), "fr", nil, 12, "fr_CA"},
{ptrText("en-GB"), "de", nil, 11, ""},
{ptrText("en"), "en", nil, 10, ""},
{nil, "de", nil, 11, ""},
{ptrText("en-GB"), "fr-CA", ptrText("fr_FR"), 41, "fr_FR"},
}
for _, c := range cases {
t.Run(c.lang+"/"+deref(c.siteLocale)+"/"+deref(c.explicit), func(t *testing.T) {
e := newLocPublicEnv(t)
e.changeSite(locJournalID, func(s *site.Site) { s.Locale = c.siteLocale })
rows := e.store.rows[locJournalID]
for index := range rows {
if rows[index].Tag == c.lang {
rows[index].SocialLocale = c.explicit
}
}
body := e.json(t, "/v1/article/"+uintText(c.id)+"/seo")
if nested(body, "og")["locale"] != c.want {
t.Errorf("og.locale = %v", nested(body, "og")["locale"])
}
})
}
})
t.Run("og:locale:alternate lists the other eligible versions that resolve", func(t *testing.T) {
e := newLocPublicEnv(t)
og := nested(e.json(t, "/v1/article/10/seo"), "og")
if og["locale"] != "en_GB" || !reflect.DeepEqual(og["locale_alternates"], []any{"fr_FR"}) {
t.Errorf("og = %v", og)
}
})
t.Run("An unresolved og:locale is an empty string and its alternates are left out", func(t *testing.T) {
e := newLocPublicEnv(t)
e.changeSite(locJournalID, func(s *site.Site) { s.Locale = nil })
og := nested(e.json(t, "/v1/article/11/seo"), "og")
if og["locale"] != "" || len(og["locale_alternates"].([]any)) != 0 {
t.Errorf("og = %v", og)
}
})
t.Run("SEO fields fall back in the language of the version", func(t *testing.T) {
cases := []struct {
seoTitle, seoDescription, excerpt, body string
title, titleSource string
description, descriptionSource string
skipped bool
}{
{"Mon titre", "Ma description.", "Un résumé.", "<p>Première.</p>", "Mon titre | Journal", "seo_title", "Ma description.", "seo_description", false},
{"", "", "Un résumé.", "<p>Première.</p>", "Comment nous chiffrons une rénovation | Journal", "title", "Un résumé.", "excerpt", false},
{"", "", "", "<p>Première.</p>", "Comment nous chiffrons une rénovation | Journal", "title", "Première.", "body", false},
{"", "", "", "", "Comment nous chiffrons une rénovation | Journal", "title", "", "none", true},
}
for _, c := range cases {
t.Run(c.descriptionSource, func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(12, func(a *article.Article) {
a.SEOTitle, a.SEODescription, a.Excerpt, a.Body = c.seoTitle, c.seoDescription, c.excerpt, c.body
})
body := e.json(t, "/v1/article/12/seo")
fallbacks := nested(body, "fallbacks")
if body["title"] != c.title || fallbacks["title"] != c.titleSource || body["description"] != c.description || fallbacks["description"] != c.descriptionSource || fallbacks["site_text_skipped"] != c.skipped {
t.Errorf("body = %v", body)
}
})
}
})
t.Run("Site default text applies only to articles in its own language", func(t *testing.T) {
cases := []struct {
id uint32
description string
source string
skipped bool
}{
{10, "Renovation notes.", "site", false},
{12, "", "none", true},
{30, "Renovation notes.", "site", false},
}
for _, c := range cases {
t.Run(uintText(c.id), func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(c.id, func(a *article.Article) { a.Body = "" })
body := e.json(t, "/v1/article/"+uintText(c.id)+"/seo")
fallbacks := nested(body, "fallbacks")
if body["description"] != c.description || fallbacks["description"] != c.source || fallbacks["site_text_skipped"] != c.skipped {
t.Errorf("body = %v", body)
}
})
}
})
t.Run("A site without a default description withholds nothing", func(t *testing.T) {
e := newLocPublicEnv(t)
e.changeSite(locJournalID, func(s *site.Site) { s.DefaultDescription = "" })
e.change(12, func(a *article.Article) { a.Body = "" })
fallbacks := nested(e.json(t, "/v1/article/12/seo"), "fallbacks")
if fallbacks["description"] != "none" || fallbacks["site_text_skipped"] != false {
t.Errorf("fallbacks = %v", fallbacks)
}
})
t.Run("Changing the default language changes which articles may use the site text", func(t *testing.T) {
e := newLocPublicEnv(t)
e.change(10, func(a *article.Article) { a.Body = "" })
rows := e.store.rows[locJournalID]
for index := range rows {
rows[index].IsDefault = rows[index].Tag == "fr"
}
fallbacks := nested(e.json(t, "/v1/article/10/seo"), "fallbacks")
if fallbacks["site_text_skipped"] != true {
t.Errorf("fallbacks = %v", fallbacks)
}
})
t.Run("The SEO read stays idempotent and public", func(t *testing.T) {
e := newLocPublicEnv(t)
counted := newMockSeoOutArticleRepo(e.articles)
e.container.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images, e.store)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings))
e.router = router
first := e.get(t, "/v1/article/12/seo", nil)
second := e.get(t, "/v1/article/12/seo", nil)
draft := e.get(t, "/v1/article/13/seo", nil)
malformed := e.get(t, "/v1/article/abc/seo", nil)
expectStatusAndText(t, draft, 404, apperr.ArticleNotFoundErrorMessage)
expectStatusAndText(t, malformed, 400, apperr.IdUint32ErrorMessage)
if first.Body.String() != second.Body.String() || counted.writes != 0 {
t.Errorf("not idempotent or wrote: %d", counted.writes)
}
})
}
func TestLocalizationSitemap(t *testing.T) {
t.Run("Each eligible version is listed once", func(t *testing.T) {
e := newLocPublicEnv(t)
rec := e.get(t, "/v1/site/journal/sitemap", nil)
var items []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &items)
urls := map[string]string{}
for _, item := range items {
if sortedKeys(item) != "lang,modified_at,noindex,url" {
t.Errorf("keys = %s", sortedKeys(item))
}
urls[item["url"].(string)] = item["lang"].(string)
}
if urls["https://journal.example.com/fr/"+locFrSlug] != "fr" || urls["https://www.journal.example/legacy-note"] != "" {
t.Errorf("urls = %v", urls)
}
if strings.Contains(rec.Body.String(), "cuanto-cuesta") || strings.Contains(rec.Body.String(), "takalif") || strings.Contains(rec.Body.String(), "winter") {
t.Errorf("an ineligible version is listed: %s", rec.Body.String())
}
if rec.Header().Get("X-Total-Count") != uintText(uint32(len(items))) {
t.Errorf("total %q for %d items", rec.Header().Get("X-Total-Count"), len(items))
}
})
t.Run("Without a pattern the legacy address is kept", func(t *testing.T) {
e := newLocPublicEnv(t)
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = nil })
if body := e.get(t, "/v1/site/journal/sitemap", nil).Body.String(); !strings.Contains(body, "https://www.journal.example/spring") {
t.Errorf("body = %s", body)
}
})
}
func ptrText(value string) *string { return &value }
func deref(value *string) string {
if value == nil {
return "null"
}
return *value
}
func uintText(id uint32) string { return strconv.FormatUint(uint64(id), 10) }
func TestLocalizationMediaUsage(t *testing.T) {
seed := func(e *locPublicEnv) {
e.seedBanner(7, "bathroom.jpg", "hash-bathroom")
for _, id := range []uint32{10, 11, 12} {
e.updateArticle(id, func(a *article.Article) { a.CoverImageID = idPointer(7) })
}
e.updateArticle(12, func(a *article.Article) { a.Body = e.bodyUsing(7) })
e.updateArticle(20, func(a *article.Article) { a.Body = e.bodyUsing(7) })
e.updateArticle(30, func(a *article.Article) { a.Body = e.bodyUsing(7) })
e.authors.seed(3, "Camille", "camille")
avatar := e.authors.authors[3]
avatar.AvatarID = idPointer(7)
e.authors.authors[3] = avatar
e.changeSite(locJournalID, func(s *site.Site) { s.DefaultSocialImageID = idPointer(7) })
}
t.Run("Article usage items carry lang and story_id", func(t *testing.T) {
e := newLocPublicEnv(t)
seed(e)
items := e.usage(t, 7)
ids := []float64{}
for _, item := range items {
if item["type"] == "article" {
ids = append(ids, item["id"].(float64))
if _, ok := item["story_id"]; !ok {
t.Errorf("story_id missing: %v", item)
}
} else if _, has := item["lang"]; has {
t.Errorf("non-article item carries lang: %v", item)
} else if _, has := item["story_id"]; has {
t.Errorf("non-article item carries story_id: %v", item)
}
}
want := map[string]any{"type": "article", "id": float64(12), "title": "Comment nous chiffrons une rénovation", "published": true, "fields": []any{"body", "cover"}, "story_id": float64(5), "lang": "fr"}
if !reflect.DeepEqual(ids, []float64{10, 11, 12, 20, 30}) || !reflect.DeepEqual(items[2], want) {
t.Errorf("ids %v item %v", ids, items[2])
}
if items[3]["story_id"] != float64(6) || items[3]["lang"] != "en" || items[4]["lang"] != "" {
t.Errorf("items = %v", items[3:])
}
})
t.Run("A version using another image as social image does not list the social field", func(t *testing.T) {
e := newLocPublicEnv(t)
seed(e)
e.seedBanner(8, "kitchen.jpg", "hash-kitchen")
e.updateArticle(10, func(a *article.Article) { a.SocialImageID = idPointer(8) })
items := e.usage(t, 7)
if fields := items[0]["fields"]; !reflect.DeepEqual(fields, []any{"cover"}) {
t.Errorf("fields = %v", fields)
}
})
t.Run("An author lookup failure is a generic 500", func(t *testing.T) {
e := newLocPublicEnv(t)
seed(e)
e.authors.fail.failNext("FindByAvatar")
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/7/usage", "")
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d", rec.Code)
}
})
t.Run("An image used by 3 versions of one story is one use", func(t *testing.T) {
e := newLocPublicEnv(t)
seed(e)
if used := e.usedIn(t, 7); used != 5 {
t.Errorf("used_in = %d", used)
}
})
}
func TestLocalizationAlternatesWithoutPattern(t *testing.T) {
e := newLocPublicEnv(t)
e.changeSite(locJournalID, func(s *site.Site) { s.PublicURLPattern = nil })
rec := e.get(t, locFrBase, nil)
if !strings.Contains(rec.Body.String(), `"url":null`) {
t.Errorf("body = %s", rec.Body.String())
}
}