Files
RenewCMS/test/site_languages_http_test.go
T

506 lines
19 KiB
Go

package test
import (
"RenewCMS/internal/domain/localization"
"net/http"
"reflect"
"slices"
"testing"
)
var languagesReadKeys = []string{"available", "default", "languages", "needs_setup", "revision"}
func TestSiteLanguagesReading(t *testing.T) {
t.Run("Read the languages", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.as(t, readerUserID, http.MethodGet, "/v1/site/journal/languages", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if keys := keysOf(decodeFields(t, rec)); !reflect.DeepEqual(keys, languagesReadKeys) {
t.Errorf("keys = %v", keys)
}
view := decodeLanguagesView(t, rec)
if view.Revision == "" || view.Default == nil || *view.Default != "en" || view.NeedsSetup {
t.Errorf("header = %+v", view)
}
if !reflect.DeepEqual(view.tags(), []string{"en", "ar", "de", "fr", "en-US", "es"}) {
t.Errorf("order = %v", view.tags())
}
counts := map[string][2]int{"en": {7, 6}, "fr": {4, 2}, "de": {4, 3}, "ar": {1, 0}, "es": {1, 0}, "en-US": {0, 0}}
for _, entry := range view.Languages {
if got := [2]int{entry.VersionCount, entry.PublishedCount}; got != counts[entry.Lang] {
t.Errorf("%s counts = %v", entry.Lang, got)
}
}
if available := view.Available; len(available) == 0 || !slices.ContainsFunc(available, func(o struct {
Lang string `json:"lang"`
Substitute *string `json:"substitute"`
}) bool {
return o.Lang == "pt-PT" && o.Substitute != nil && *o.Substitute == "pt"
}) {
t.Errorf("available = %+v", available)
}
})
t.Run("Read access", func(t *testing.T) {
cases := []struct {
name string
user uint32
key string
status int
}{
{"an anonymous client", 0, "journal", http.StatusUnauthorized},
{"a reader", readerUserID, "journal", http.StatusOK},
{"an owner", apiKeyOwnerId, "journal", http.StatusOK},
{"an owner on an unknown site", apiKeyOwnerId, "nowhere", http.StatusNotFound},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.as(t, c.user, http.MethodGet, "/v1/site/"+c.key+"/languages", "")
if rec.Code != c.status {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if c.status == http.StatusNotFound && bodyText(rec) != "site_not_found" {
t.Errorf("body = %q", bodyText(rec))
}
})
}
})
t.Run("Social locale states", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(5, "Atelier", "atelier", false)
fr := "fr_FR"
e.seedRows(5,
localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true},
localization.Row{Tag: "en-GB", Enabled: true},
localization.Row{Tag: "fr", Enabled: true, SocialLocale: &fr},
localization.Row{Tag: "pt-BR", Enabled: true},
)
cases := []struct {
lang string
state string
explicit *string
effective *string
}{
{"en-GB", "derived", nil, localeOf("en_GB")},
{"pt-BR", "derived", nil, localeOf("pt_BR")},
{"fr", "set", &fr, &fr},
{"en", "not_set", nil, nil},
}
rec := e.owner(t, http.MethodGet, "/v1/site/atelier/languages", "")
view := decodeLanguagesView(t, rec)
for _, c := range cases {
t.Run(c.lang, func(t *testing.T) {
for _, entry := range view.Languages {
if entry.Lang != c.lang {
continue
}
if entry.State != c.state || !sameText(entry.SocialLocale, c.explicit) {
t.Errorf("entry = %+v", entry)
}
row := localization.Row{Tag: entry.Lang, SocialLocale: entry.SocialLocale}
if !sameText(localization.EffectiveSocialLocale(row, nil), c.effective) {
t.Errorf("effective = %v", localization.EffectiveSocialLocale(row, nil))
}
}
})
}
})
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"
stored := e.sites.sites[5]
stored.Locale = &gb
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 != "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)
}
})
t.Run("A site that still needs setup", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(2, "Notes", "notes", false)
before := e.store.replaceCalls
view := decodeLanguagesView(t, e.as(t, readerUserID, http.MethodGet, "/v1/site/notes/languages", ""))
put := e.owner(t, http.MethodPut, "/v1/site/notes/languages", languagesBody("any", "en", entryJSON("en", true, "null")))
if !view.NeedsSetup {
t.Errorf("needs_setup = false")
}
expectSiteStatusAndBody(t, put, http.StatusConflict, "setup_required")
if e.store.replaceCalls != before {
t.Errorf("something was stored")
}
})
t.Run("Sites created after the migration have no language row", func(t *testing.T) {
e := newLocalizationEnv(t)
e.sites.seed(2, "Fresh", "fresh", false)
view := decodeLanguagesView(t, e.owner(t, http.MethodGet, "/v1/site/fresh/languages", ""))
if !view.NeedsSetup || len(view.Languages) != 0 || view.Default != nil {
t.Errorf("view = %+v", view)
}
})
}
func sameText(left *string, right *string) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func TestSiteLanguagesWriting(t *testing.T) {
put := func(e *localizationEnv, t *testing.T, defaultTag string, entries []string) (string, string) {
t.Helper()
revision := e.revision(t, "journal")
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, defaultTag, entries...))
return rec.Body.String(), http.StatusText(rec.Code)
}
_ = put
changes := []struct {
name string
def string
entries []string
check func(t *testing.T, view languagesView)
}{
{"fr added with enabled true", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_FR"`)}), func(t *testing.T, v languagesView) {
if countTag(v, "fr") != 1 || !findLanguage(t, v, "fr").Enabled {
t.Errorf("fr = %+v", v.tags())
}
}},
{"pt-br added (lowercase) as enabled", "en", append(journalEntries(nil), entryJSON("pt-br", true, "null")), func(t *testing.T, v languagesView) {
if countTag(v, "pt-BR") != 1 || countTag(v, "pt-br") != 0 {
t.Errorf("tags = %v", v.tags())
}
}},
{"default fr", "fr", journalEntries(nil), func(t *testing.T, v languagesView) {
if !findLanguage(t, v, "fr").IsDefault || findLanguage(t, v, "en").IsDefault || !findLanguage(t, v, "en").Enabled {
t.Errorf("view = %+v", v.Languages)
}
}},
{"de enabled false", "en", journalEntries(map[string]string{"de": entryJSON("de", false, "null")}), func(t *testing.T, v languagesView) {
de := findLanguage(t, v, "de")
if de.Enabled || de.VersionCount != 4 || de.PublishedCount != 3 {
t.Errorf("de = %+v", de)
}
}},
{"es enabled true", "en", journalEntries(map[string]string{"es": entryJSON("es", true, "null")}), func(t *testing.T, v languagesView) {
es := findLanguage(t, v, "es")
if !es.Enabled || es.VersionCount != 1 {
t.Errorf("es = %+v", es)
}
}},
{"en-US omitted", "en", journalEntries(nil, "en-US"), func(t *testing.T, v languagesView) {
if countTag(v, "en-US") != 0 {
t.Errorf("tags = %v", v.tags())
}
}},
{"fr social_locale fr_CA", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_CA"`)}), func(t *testing.T, v languagesView) {
fr := findLanguage(t, v, "fr")
if fr.State != "set" || fr.SocialLocale == nil || *fr.SocialLocale != "fr_CA" {
t.Errorf("fr = %+v", fr)
}
}},
{"fr social_locale null", "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, "null")}), func(t *testing.T, v languagesView) {
if findLanguage(t, v, "fr").State != "not_set" {
t.Errorf("fr = %+v", findLanguage(t, v, "fr"))
}
}},
}
for _, c := range changes {
t.Run("Changing the set/"+c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
articlesBefore := len(e.articles.articles)
revision := e.revision(t, "journal")
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, c.def, c.entries...))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
c.check(t, decodeLanguagesView(t, rec))
if len(e.articles.articles) != articlesBefore {
t.Errorf("articles changed")
}
for _, stored := range e.articles.articles {
if stored.Lang == "" {
t.Errorf("an article lost its language")
}
}
})
}
rejections := []struct {
name string
def string
entries func() []string
status int
code string
}{
{"default en with en enabled false", "en", func() []string {
return journalEntries(map[string]string{"en": entryJSON("en", false, "null")})
}, 422, "default_not_enabled"},
{"default ja", "ja", func() []string { return journalEntries(nil) }, 422, "default_not_enabled"},
{"every language enabled false", "en", func() []string {
entries := []string{}
for _, tag := range []string{"en", "fr", "de", "ar", "es", "en-US"} {
entries = append(entries, entryJSON(tag, false, "null"))
}
return entries
}, 422, "no_enabled_language"},
{"languages []", "en", func() []string { return nil }, 422, "no_enabled_language"},
{"es omitted (1 version)", "en", func() []string { return journalEntries(nil, "es") }, 409, "language_in_use"},
{"ar omitted (1 draft or offline version)", "en", func() []string { return journalEntries(nil, "ar") }, 409, "language_in_use"},
{"en omitted", "en", func() []string { return journalEntries(nil, "en") }, 422, "default_not_enabled"},
{"a language english", "en", func() []string { return append(journalEntries(nil), entryJSON("english", true, "null")) }, 422, "invalid_language"},
{"a language xx", "en", func() []string { return append(journalEntries(nil), entryJSON("xx", true, "null")) }, 422, "unsupported_language"},
{"a language pt-PT", "en", func() []string { return append(journalEntries(nil), entryJSON("pt-PT", true, "null")) }, 422, "unsupported_language"},
{"the same tag twice", "en", func() []string {
return append(journalEntries(nil), entryJSON("fr", true, "null"), entryJSON("FR", true, "null"))
}, 422, "duplicate_language"},
{"fr social_locale fr-FR", "en", func() []string { return socialOnFrench(`"fr-FR"`) }, 422, "invalid_social_locale"},
{"fr social_locale fr", "en", func() []string { return socialOnFrench(`"fr"`) }, 422, "invalid_social_locale"},
{"fr social_locale empty", "en", func() []string { return socialOnFrench(`""`) }, 422, "invalid_social_locale"},
{"fr social_locale pt_BR", "en", func() []string { return socialOnFrench(`"pt_BR"`) }, 422, "invalid_social_locale"},
{"fr default, de disabled and es omitted", "fr", func() []string {
return journalEntries(map[string]string{"de": entryJSON("de", false, "null")}, "es")
}, 409, "language_in_use"},
}
for _, c := range rejections {
t.Run("Rules that reject a change/"+c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rowsBefore, articlesBefore := e.store.snapshot()
revision := e.revision(t, "journal")
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, c.def, c.entries()...))
expectSiteStatusAndBody(t, rec, c.status, c.code)
rowsAfter, articlesAfter := e.store.snapshot()
if !reflect.DeepEqual(rowsBefore, rowsAfter) || !reflect.DeepEqual(articlesBefore, articlesAfter) || e.store.replaceCalls != 0 {
t.Errorf("something was stored")
}
})
}
t.Run("Rules that reject a change/no revision key", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", `{"default":"en","languages":[`+entryJSON("en", true, "null")+`]}`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body.")
})
t.Run("Rules that reject a change/malformed JSON", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
rec := e.owner(t, http.MethodPut, "/v1/site/journal/languages", `{not json`)
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body.")
})
t.Run("Social locale format and language part", func(t *testing.T) {
cases := []struct {
tag string
social string
valid bool
}{
{"fr", "fr_FR", true},
{"fr", "fr_CA", true},
{"fr", "pt_BR", false},
{"fr-CA", "fr_CA", true},
{"ar", "ara_EG", false},
{"fr", "fr_F", false},
{"fr", "fr__FR", false},
{"fr", "fr_F1", false},
}
for _, c := range cases {
social := c.social
_, err := localization.NormalizeSocialLocale(c.tag, &social)
if (err == nil) != c.valid {
t.Errorf("%s on %s: err = %v", c.social, c.tag, err)
}
}
if normalized, err := localization.NormalizeSocialLocale("fr", localeOf("fr_fr")); err != nil || *normalized != "fr_FR" {
t.Errorf("normalized = %v, %v", normalized, err)
}
})
t.Run("A stale revision is refused", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
stale := e.revision(t, "journal")
other := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(stale, "en", journalEntries(map[string]string{"fr": entryJSON("fr", true, `"fr_CA"`)})...))
if other.Code != http.StatusOK {
t.Fatalf("other owner: %d %s", other.Code, other.Body)
}
rowsBefore, _ := e.store.snapshot()
refused := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(stale, "fr", journalEntries(nil)...))
expectSiteStatusAndBody(t, refused, http.StatusConflict, "languages_changed")
if rowsAfter, _ := e.store.snapshot(); !reflect.DeepEqual(rowsBefore, rowsAfter) {
t.Errorf("rows changed")
}
retry := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "fr", journalEntries(nil)...))
if retry.Code != http.StatusOK {
t.Errorf("retry: %d %s", retry.Code, retry.Body)
}
})
t.Run("Sending the same state again is a no-op", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
first := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "fr", journalEntries(nil)...))
r2 := decodeLanguagesView(t, first).Revision
calls := e.store.replaceCalls
rowsBefore, _ := e.store.snapshot()
again := e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(r2, "fr", journalEntries(nil)...))
if again.Code != http.StatusOK || decodeLanguagesView(t, again).Revision != r2 {
t.Fatalf("again: %d %s", again.Code, again.Body)
}
if rowsAfter, _ := e.store.snapshot(); e.store.replaceCalls != calls || !reflect.DeepEqual(rowsBefore, rowsAfter) {
t.Errorf("rows were written again")
}
})
t.Run("Only an owner can write", func(t *testing.T) {
cases := []struct {
name string
user uint32
status int
}{
{"an anonymous client", 0, http.StatusUnauthorized},
{"a reader", readerUserID, http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
revision := e.revision(t, "journal")
rec := e.as(t, c.user, http.MethodPut, "/v1/site/journal/languages", languagesBody(revision, "fr", journalEntries(nil)...))
if rec.Code != c.status || e.store.replaceCalls != 0 {
t.Errorf("status %d, writes %d", rec.Code, e.store.replaceCalls)
}
})
}
})
t.Run("Removing a language is decided under the site lock", func(t *testing.T) {
e := newLocalizationEnv(t)
e.seedJournal()
e.owner(t, http.MethodPut, "/v1/site/journal/languages", languagesBody(e.revision(t, "journal"), "en", journalEntries(nil, "en-US")...))
if !slices.Equal(e.sites.lockedSites, []uint32{journalSiteID}) || e.transactor.runs != 1 {
t.Errorf("locks %v, transactions %d", e.sites.lockedSites, e.transactor.runs)
}
})
}
func socialOnFrench(social string) []string {
return journalEntries(map[string]string{"fr": entryJSON("fr", true, social)})
}
func countTag(view languagesView, tag string) int {
total := 0
for _, entry := range view.Languages {
if entry.Lang == tag {
total++
}
}
return total
}
type languageEntryView = struct {
Lang string `json:"lang"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"is_default"`
VersionCount int `json:"version_count"`
PublishedCount int `json:"published_count"`
SocialLocale *string `json:"social_locale"`
State string `json:"social_locale_state"`
}
func findLanguage(t *testing.T, view languagesView, tag string) languageEntryView {
t.Helper()
for _, entry := range view.Languages {
if entry.Lang == tag {
return entry
}
}
t.Fatalf("language %s missing from %v", tag, view.tags())
return languageEntryView{}
}
func TestSiteLanguagesRevision(t *testing.T) {
base := []localization.Row{{Tag: "en", IsDefault: true, Enabled: true}, {Tag: "fr", Enabled: true}}
other := func(change func(rows []localization.Row)) string {
rows := slices.Clone(base)
change(rows)
return localization.Revision(rows)
}
reference := localization.Revision(base)
t.Run("the same rows give the same revision whatever the order", func(t *testing.T) {
if localization.Revision([]localization.Row{base[1], base[0]}) != reference {
t.Errorf("order changed the revision")
}
})
t.Run("any change of lang, default, enabled or social_locale gives another one", func(t *testing.T) {
changes := map[string]func(rows []localization.Row){
"lang": func(rows []localization.Row) { rows[1].Tag = "de" },
"default": func(rows []localization.Row) { rows[1].IsDefault = true },
"enabled": func(rows []localization.Row) { rows[1].Enabled = false },
"social": func(rows []localization.Row) { rows[1].SocialLocale = localeOf("fr_FR") },
}
for name, change := range changes {
if other(change) == reference {
t.Errorf("%s did not change the revision", name)
}
}
})
t.Run("confirmed flags do not change the revision", func(t *testing.T) {
if other(func(rows []localization.Row) { rows[0].Confirmed = true }) != reference {
t.Errorf("confirmed changed the revision")
}
})
}