feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user