833 lines
30 KiB
Go
833 lines
30 KiB
Go
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())
|
|
}
|
|
}
|