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: "
" + title + ".
", 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é.", "Première.
", "Mon titre | Journal", "seo_title", "Ma description.", "seo_description", false}, {"", "", "Un résumé.", "Première.
", "Comment nous chiffrons une rénovation | Journal", "title", "Un résumé.", "excerpt", false}, {"", "", "", "Première.
", "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()) } }