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

+15
View File
@@ -129,6 +129,9 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
if filters.Lang != nil && a.Lang != *filters.Lang {
continue
}
if filters.Langs != nil && !slices.Contains(*filters.Langs, a.Lang) {
continue
}
if filters.AuthorID != nil && !slices.Contains(m.credits[a.ID], *filters.AuthorID) {
continue
}
@@ -460,6 +463,18 @@ func (m *mockArticleRepo) storyHasLanguage(storyID uint32, lang string) bool {
return false
}
func (m *mockArticleRepo) GetByLangSlug(siteID uint32, lang string, slug string) (article.Article, error) {
if err := m.fail.trigger("GetByLangSlug"); err != nil {
return article.Article{}, err
}
for _, id := range m.sortedIds() {
if stored := m.articles[id]; stored.SiteID == siteID && stored.Lang == lang && stored.Slug == slug {
return m.withAuthors(stored), nil
}
}
return article.Article{}, article.ErrArticleNotFound
}
func (m *mockArticleRepo) ListByStory(storyID uint32) ([]article.Article, error) {
if err := m.fail.trigger("ListByStory"); err != nil {
return nil, err
+3
View File
@@ -283,6 +283,9 @@ func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) {
}
func (m *mockAuthorRepo) FindByAvatar(imageID uint32) ([]author.Author, error) {
if err := m.fail.trigger("FindByAvatar"); err != nil {
return nil, err
}
found := []author.Author{}
for _, id := range m.sortedIDsAscending() {
if a := m.authors[id]; a.AvatarID != nil && *a.AvatarID == imageID {
+50
View File
@@ -0,0 +1,50 @@
package test
import (
"RenewCMS/internal/domain/localization"
"reflect"
"testing"
)
func TestLocalizationCompareTags(t *testing.T) {
examples := []struct {
name string
left string
right string
want int
}{
{"same tag", "fr", "fr", 0},
{"same default tag", "en", "en", 0},
{"default first", "en", "de", -1},
{"default last argument", "de", "en", 1},
{"alphabetical before", "de", "fr", -1},
{"alphabetical after", "fr", "de", 1},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
if got := localization.CompareTags("en", example.left, example.right); got != example.want {
t.Errorf("CompareTags(%q, %q) = %d, want %d", example.left, example.right, got, example.want)
}
})
}
}
func TestLocalizationBuildViewOrdersDefaultThenEnabledThenTag(t *testing.T) {
rows := []localization.Row{
{Tag: "pt", Enabled: false, Confirmed: true},
{Tag: "fr", Enabled: true, Confirmed: true},
{Tag: "es", Enabled: false, Confirmed: true},
{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
{Tag: "de", Enabled: true, Confirmed: true},
}
view := localization.BuildView(rows, map[string]localization.VersionCount{}, nil)
order := []string{}
for _, language := range view.Languages {
order = append(order, language.Lang)
}
if want := []string{"en", "de", "fr", "es", "pt"}; !reflect.DeepEqual(order, want) {
t.Errorf("order = %v, want %v", order, want)
}
}
+42
View File
@@ -1,6 +1,7 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
@@ -229,6 +230,47 @@ func TestLocalizationPersistence(t *testing.T) {
t.Errorf("retry = %+v, %v", again, err)
}
}},
{"Exact lookup and story resolution read the same rows", func(t *testing.T, db *gorm.DB) {
created := seedLocalizedSite(t, db, "lookups", nil, nil)
repository := persistence.NewArticleRepository(db)
story := fixtureStory()
published := entity.Article{Title: "Published", SiteID: created.ID, Lang: "fr", StoryID: story, Slug: "publie", IsOnline: true}
offline := entity.Article{Title: "Offline", SiteID: created.ID, Lang: "en", StoryID: story, Slug: "offline"}
unassigned := entity.Article{Title: "Unassigned", SiteID: created.ID, StoryID: fixtureStory(), Slug: "legacy", IsOnline: true}
removed := entity.Article{Title: "Removed", SiteID: created.ID, Lang: "de", StoryID: story, Slug: "weg", IsOnline: true}
for _, stored := range []*entity.Article{&published, &offline, &unassigned, &removed} {
if err := db.Create(stored).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Delete(&removed).Error; err != nil {
t.Fatal(err)
}
found, err := repository.GetByLangSlug(created.ID, "fr", "publie")
missingLanguage, missingErr := repository.GetByLangSlug(created.ID, "en", "publie")
_, removedErr := repository.GetByLangSlug(created.ID, "de", "weg")
_, unassignedErr := repository.GetByLangSlug(created.ID, "en", "legacy")
versions, listErr := repository.ListByStory(story)
if err != nil || found.ID != published.ID || !found.IsOnline {
t.Errorf("exact lookup = %+v, %v", found, err)
}
if !errors.Is(missingErr, domainArticle.ErrArticleNotFound) || missingLanguage.ID != 0 || !errors.Is(removedErr, domainArticle.ErrArticleNotFound) || !errors.Is(unassignedErr, domainArticle.ErrArticleNotFound) {
t.Errorf("misses = %v %v %v", missingErr, removedErr, unassignedErr)
}
if listErr != nil || len(versions) != 2 {
t.Errorf("story versions = %d, %v", len(versions), listErr)
}
for _, name := range []string{"idx_articles_site_slug", "idx_articles_story_lang"} {
if !db.Migrator().HasIndex(&entity.Article{}, name) {
t.Errorf("index %s is missing", name)
}
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Errorf("a second migration failed: %v", err)
}
}},
}
for _, backend := range backends {
for _, c := range cases {
+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())
}
}
+15
View File
@@ -92,6 +92,7 @@ func TestMCPToolsMapToTheAPIWithTheKey(t *testing.T) {
body string
}{
{"list_articles", map[string]any{"page": 2, "size": 5, "status": "draft", "q": "tile"}, "GET", "/v1/article/?page=2&q=tile&size=5&status=draft", ""},
{"list_articles", map[string]any{"lang": "pt-BR"}, "GET", "/v1/article/?lang=pt-BR", ""},
{"get_article", map[string]any{"id": 7, "format": "html"}, "GET", "/v1/article/7?format=html", ""},
{"create_article", map[string]any{"title": "Grout tips", "body": "<p>x</p>"}, "POST", "/v1/article/", `{"body":"<p>x</p>","title":"Grout tips"}`},
{"update_article_body", map[string]any{"id": 7, "body": "<p>y</p>"}, "PATCH", "/v1/article/7", `{"body":"<p>y</p>"}`},
@@ -187,3 +188,17 @@ func sameJSONBody(got string, want string) bool {
}
return reflect.DeepEqual(a, b)
}
func TestMCPToolsNoLongerSayATitleCannotChange(t *testing.T) {
f := newMCPFixture(t, false, echoStatus(200, `{"ok":true}`))
tools, err := f.session.ListTools(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
schemas, _ := json.Marshal(tools.Tools)
if strings.Contains(string(schemas), "cannot be changed") {
t.Errorf("a tool still says that a title cannot be changed")
}
}
+1 -1
View File
@@ -424,7 +424,7 @@ func TestMediaFieldTypeLibrary(t *testing.T) {
if rec.Code != http.StatusOK || len(items) != 5 {
t.Fatalf("status %d items %v", rec.Code, items)
}
wantArticle := map[string]any{"type": "article", "id": float64(12), "title": "Heat pumps", "published": true, "fields": []any{"cover"}}
wantArticle := map[string]any{"type": "article", "id": float64(12), "title": "Heat pumps", "published": true, "fields": []any{"cover"}, "story_id": float64(0), "lang": ""}
if !reflect.DeepEqual(items[0], wantArticle) {
t.Errorf("article item %v", items[0])
}
+17 -1
View File
@@ -737,7 +737,7 @@ func TestMediaUsage(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
want := `[{"type": "article", "id": 1, "title": "Kitchen Renovation", "published": true, "fields": ["body"]}, {"type": "article", "id": 2, "title": "Backyard Notes", "published": false, "fields": ["body"]}]`
want := `[{"type": "article", "id": 1, "title": "Kitchen Renovation", "published": true, "fields": ["body"], "story_id": 0, "lang": ""}, {"type": "article", "id": 2, "title": "Backyard Notes", "published": false, "fields": ["body"], "story_id": 0, "lang": ""}]`
expectJSONEqual(t, rec.Body.String(), want)
})
@@ -806,6 +806,22 @@ func TestMediaUsage(t *testing.T) {
})
}
func TestMediaDetailRepositoryFailures(t *testing.T) {
for _, method := range []string{"FindByBodyContaining", "FindByImage"} {
t.Run(method+" failing is a generic 500", func(t *testing.T) {
env := newMediaEnv(t)
seedListingLibrary(env)
env.articles.fail.failNext(method)
rec := env.send(t, http.MethodGet, "/v1/media/1", "", mediaVerifiedUserId)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", rec.Code)
}
})
}
}
func expectJSONEqual(t *testing.T, got string, want string) {
t.Helper()
var gotValue, wantValue any
+1 -1
View File
@@ -324,7 +324,7 @@ func TestContentScopeArticleUseCases(t *testing.T) {
t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) {
repo, sites, _, images := newRepos()
check := useCases.NewCheckArticleSEOUseCase(repo, sites, images)
resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images)
resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images, plainLocalization())
reader := callerWith(22, "content.read=own")
if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil {
t.Errorf("own draft: %v", err)
+1 -1
View File
@@ -217,7 +217,7 @@ func (w *contentScopeWorld) wire() {
c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images, plainLocalization())
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
+3 -3
View File
@@ -183,8 +183,8 @@ func TestSeoDataMediaUsage(t *testing.T) {
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5/usage", "")
want := `[{"type": "article", "id": 3, "title": "Body article", "published": true, "fields": ["body"]},
{"type": "article", "id": 8, "title": "Cover article", "published": false, "fields": ["cover"]},
want := `[{"type": "article", "id": 3, "title": "Body article", "published": true, "fields": ["body"], "story_id": 3, "lang": ""},
{"type": "article", "id": 8, "title": "Cover article", "published": false, "fields": ["cover"], "story_id": 8, "lang": ""},
{"type": "author", "id": 2, "title": "Camille", "fields": ["avatar"]},
{"type": "site", "id": 1, "title": "Main", "fields": ["default_social_image"]}]`
if rec.Code != http.StatusOK {
@@ -204,7 +204,7 @@ func TestSeoDataMediaUsage(t *testing.T) {
for key := range items[0] {
keys = append(keys, key)
}
if len(items) != 1 || items[0]["id"] != float64(3) || items[0]["title"] != "Body article" || items[0]["published"] != true || len(keys) != 5 {
if len(items) != 1 || items[0]["id"] != float64(3) || items[0]["title"] != "Body article" || items[0]["published"] != true || len(keys) != 7 {
t.Errorf("item = %v", items)
}
})
+5 -5
View File
@@ -23,11 +23,11 @@ func TestSeoOutputResolver(t *testing.T) {
}
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",
"": "alternates,authors,canonical_url,description,fallbacks,image,json_ld,lang,modified_at,og,published_at,robots,title,twitter",
"og": "description,image,image_alt,locale,locale_alternates,site_name,title,type,url",
"twitter": "card,description,image,image_alt,title",
"image": "alt,source,url",
"fallbacks": "canonical,description,image,title",
"fallbacks": "canonical,description,image,site_text_skipped,title",
}
for key, want := range cases {
target := object
@@ -49,9 +49,9 @@ func TestSeoOutputResolver(t *testing.T) {
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"},
"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", "locale_alternates": []any{}},
"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"},
"fallbacks": map[string]any{"title": "title", "description": "body", "image": "site", "canonical": "site", "site_text_skipped": false},
"published_at": "2026-09-01T08:00:00Z", "modified_at": "2026-09-10T09:30:00Z",
}
for key, expected := range want {
+3 -3
View File
@@ -42,8 +42,8 @@ func TestSeoOutputSitemap(t *testing.T) {
e.articles.articles[20] = article.Article{ID: 20, Title: "Twenty", IsOnline: true, SiteID: seoOutJournalID, Slug: "twenty", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified}
rec, items := e.sitemap(t, "", "")
want := []map[string]any{
{"url": seoOutBase + "/" + seoOutArticleSlug, "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
{"url": seoOutBase + "/sixteen", "modified_at": "2026-09-10T09:30:00Z", "noindex": false},
{"url": seoOutBase + "/" + seoOutArticleSlug, "modified_at": "2026-09-10T09:30:00Z", "noindex": false, "lang": ""},
{"url": seoOutBase + "/sixteen", "modified_at": "2026-09-10T09:30:00Z", "noindex": false, "lang": ""},
}
if rec.Code != http.StatusOK || !reflect.DeepEqual(items, want) || rec.Header().Get("X-Total-Count") != "2" {
t.Errorf("status %d total %q items %v", rec.Code, rec.Header().Get("X-Total-Count"), items)
@@ -97,7 +97,7 @@ func TestSeoOutputSitemap(t *testing.T) {
t.Run("Each item has exactly the documented keys", func(t *testing.T) {
e := newSeoOutEnv(t)
_, items := e.sitemap(t, "", "")
if sortedKeys(items[0]) != "modified_at,noindex,url" {
if sortedKeys(items[0]) != "lang,modified_at,noindex,url" {
t.Errorf("keys = %s", sortedKeys(items[0]))
}
})
+2 -2
View File
@@ -129,9 +129,9 @@ func newSeoOutEnv(t *testing.T) *seoOutEnv {
e.sites.sites[seoOutJournalID] = journal
e.seedBanner(seoOutSiteImage, "site.png", "hash-site")
c := e.container
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images, plainLocalization())
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images)
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites)
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites, plainLocalization())
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites, plainLocalization())
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
+24 -4
View File
@@ -106,15 +106,15 @@ func TestSiteLanguagesReading(t *testing.T) {
t.Errorf("entry = %+v", entry)
}
row := localization.Row{Tag: entry.Lang, SocialLocale: entry.SocialLocale}
if !sameText(localization.EffectiveSocialLocale(row), c.effective) {
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row))
if !sameText(localization.EffectiveSocialLocale(row, nil), c.effective) {
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row, nil))
}
}
})
}
})
t.Run("Only the explicit value and the tag's own region resolve the social locale", func(t *testing.T) {
t.Run("A bare tag borrows the region of Site.Locale", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(5, "Atelier", "atelier", false)
gb := "en-GB"
@@ -125,7 +125,27 @@ func TestSiteLanguagesReading(t *testing.T) {
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/atelier/languages", ""))
if view.Languages[0].State != "not_set" {
if view.Languages[0].State != "derived" {
t.Errorf("state = %q", view.Languages[0].State)
}
row := localization.Row{Tag: "en"}
if !sameText(localization.EffectiveSocialLocale(row, &gb), localeOf("en_GB")) {
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row, &gb))
}
})
t.Run("No region anywhere stays not set", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(5, "Atelier", "atelier", false)
bare := "en"
stored := e.sites.sites[5]
stored.Locale = &bare
e.sites.sites[5] = stored
e.seedRows(5, localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true})
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/atelier/languages", ""))
if view.Languages[0].State != "not_set" || localization.EffectiveSocialLocale(localization.Row{Tag: "en"}, &bare) != nil {
t.Errorf("state = %q", view.Languages[0].State)
}
})