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") } }) }