internal/server/i18n_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"io/fs"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"regexp"
"strings"
"testing"
"testing/fstest"
"github.com/X4Applegate/caddyui/internal/i18n"
"github.com/X4Applegate/caddyui/internal/models"
"github.com/X4Applegate/caddyui/web"
)
// v2.62.0 (issue #128): localization plumbing.
var tKeyRe = regexp.MustCompile(`\{\{-?\s*t \$ "([a-z0-9_.]+)"|\(t \$ "([a-z0-9_.]+)"\)|caddyuiT\(['"]([a-z0-9_.]+)['"]`)
// Every key a template or script uses must exist in English — a typo would
// otherwise show the raw key on the page.
func TestEveryTranslationKeyUsedExistsInEnglish(t *testing.T) {
en := translations().Catalog("en")
if len(en) < 10 {
t.Fatalf("the embedded English catalog did not load (%d keys)", len(en))
}
used := 0
err := fs.WalkDir(web.FS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !(strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js")) {
return err
}
raw, _ := fs.ReadFile(web.FS, path)
for _, m := range tKeyRe.FindAllStringSubmatch(string(raw), -1) {
key := m[1] + m[2] + m[3]
used++
if _, ok := en[key]; !ok {
t.Errorf("%s uses %q, which en.json does not define", path, key)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if used < 30 {
t.Errorf("only %d translated strings found — the template scan is probably broken", used)
}
}
// A translation may only use keys English has, and must keep the same {placeholders}.
func TestEveryCatalogMatchesEnglishKeysAndPlaceholders(t *testing.T) {
b := translations()
en := b.Catalog("en")
for _, l := range b.Locales() {
if l.Code == "en" {
continue
}
for k, v := range b.Catalog(l.Code) {
if strings.HasPrefix(k, "_meta.") {
continue
}
ev, ok := en[k]
if !ok {
t.Errorf("%s.json has %q, which en.json does not define", l.Code, k)
continue
}
if v != "" && !reflect.DeepEqual(i18n.Placeholders(v), i18n.Placeholders(ev)) {
t.Errorf("%s.json %q has placeholders %v, English has %v", l.Code, k, i18n.Placeholders(v), i18n.Placeholders(ev))
}
}
}
}
func TestRequestLocalePrefersUserThenSiteDefaultThenBrowser(t *testing.T) {
s := newHTTPOnlyTestServer(t)
en, _ := fs.ReadFile(web.FS, "i18n/en.json")
b, err := i18n.Load(fstest.MapFS{
"i18n/en.json": {Data: en},
"i18n/zh-CN.json": {Data: []byte(`{"_meta.name":"简体中文"}`)},
"i18n/de.json": {Data: []byte(`{"_meta.name":"Deutsch"}`)},
}, "i18n")
if err != nil {
t.Fatal(err)
}
translations()
orig := i18nBundle
i18nBundle = b
t.Cleanup(func() { i18nBundle = orig })
req := func(accept string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if accept != "" {
r.Header.Set("Accept-Language", accept)
}
return r
}
// No default set: the browser decides, else English.
if got := s.requestLocale(req("de-DE,de;q=0.9"), nil); got != "de" {
t.Errorf("browser de → %q", got)
}
if got := s.requestLocale(req("fr-FR"), nil); got != "en" {
t.Errorf("nothing matches → %q, want en", got)
}
// An administrator's default beats the browser — even an English browser.
_ = models.SetSetting(s.DB, settingDefaultLocale, "zh-CN")
if got := s.requestLocale(req("en-US,en;q=0.9"), nil); got != "zh-CN" {
t.Errorf("site default zh-CN with an English browser → %q (the bug: the default never applied)", got)
}
// …but a person's own choice beats the default.
if got := s.requestLocale(req("en-US"), &models.User{Locale: "de"}); got != "de" {
t.Errorf("own choice → %q", got)
}
// An unknown own choice or default is ignored rather than breaking the page.
if got := s.requestLocale(req(""), &models.User{Locale: "xx-YY"}); got != "zh-CN" {
t.Errorf("unknown own choice → %q, want the site default", got)
}
_ = models.SetSetting(s.DB, settingDefaultLocale, "klingon")
if got := s.requestLocale(req("de"), nil); got != "de" {
t.Errorf("unknown default → %q, want the browser's", got)
}
}
func TestTemplateHelperTakesThePageDataOrALocale(t *testing.T) {
if got := tFunc(map[string]any{"Lang": "en"}, "nav.proxy_hosts"); got != "Proxy Hosts" {
t.Errorf("t $ → %q", got)
}
if got := tFunc("en", "nav.proxy_hosts"); got != "Proxy Hosts" {
t.Errorf("t \"en\" → %q", got)
}
if got := tFunc(nil, "nav.proxy_hosts"); got != "Proxy Hosts" {
t.Errorf("t with no context → %q", got)
}
}
// English pages render exactly as before: the translated navigation and
// headings read the same, and <html lang> plus the browser-side helper exist.
func TestRenderedPagesStayEnglishAndCarryTheLanguage(t *testing.T) {
e := newSecEnv(t)
body := e.do(t, "admin", http.MethodGet, "/proxy-hosts", nil).Body.String()
for _, want := range []string{`<html lang="en"`, ">Proxy Hosts<", ">Middleware Profiles<", "<h1>Proxy hosts</h1>", "Search resources and actions", "window.CADDYUI_I18N", `"js.copied":"Copied"`, "Sign out"} {
if !strings.Contains(body, want) {
t.Errorf("the page is missing %q", want)
}
}
if strings.Contains(body, "nav.proxy_hosts") || strings.Contains(body, "topbar.") {
t.Error("a raw translation key leaked into the page")
}
}
func TestProfileLanguageChoiceIsSavedAndValidated(t *testing.T) {
e := newSecEnv(t)
page := e.do(t, "alice", http.MethodGet, "/profile", nil).Body.String()
if !strings.Contains(page, `name="locale"`) || !strings.Contains(page, ">English<") || !strings.Contains(page, "Use my browser") {
t.Fatalf("the profile page has no language picker")
}
rec := e.do(t, "alice", http.MethodPost, "/profile", url.Values{"action": {"update_locale"}, "locale": {"en"}})
if rec.Code != http.StatusFound || !strings.Contains(rec.Header().Get("Location"), "flash=") {
t.Fatalf("save → %d %s", rec.Code, rec.Header().Get("Location"))
}
u, _ := models.GetUserByID(e.db, e.ids["alice"])
if u.Locale != "en" {
t.Errorf("stored locale = %q", u.Locale)
}
rec = e.do(t, "alice", http.MethodPost, "/profile", url.Values{"action": {"update_locale"}, "locale": {"../../etc"}})
if !strings.Contains(rec.Header().Get("Location"), "error=") {
t.Errorf("an unknown language was accepted: %s", rec.Header().Get("Location"))
}
u, _ = models.GetUserByID(e.db, e.ids["alice"])
if u.Locale != "en" {
t.Errorf("a refused value changed the stored locale to %q", u.Locale)
}
// Back to "follow the browser".
e.do(t, "alice", http.MethodPost, "/profile", url.Values{"action": {"update_locale"}, "locale": {""}})
if u, _ = models.GetUserByID(e.db, e.ids["alice"]); u.Locale != "" {
t.Errorf("clearing the choice left %q", u.Locale)
}
}
func TestSettingsDefaultLanguageIsValidated(t *testing.T) {
s, _ := newPreflightTestServer(t)
if rec := postSettingsPage(t, s, url.Values{"settings_section": {"general"}, "default_locale": {"en"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("save → %d", rec.Code)
}
if got := setting(t, s, settingDefaultLocale); got != "en" {
t.Errorf("stored %q", got)
}
if rec := postSettingsPage(t, s, url.Values{"settings_section": {"general"}, "default_locale": {"klingon"}}); rec.Code != http.StatusBadRequest {
t.Errorf("an unknown default language → %d, want 400", rec.Code)
}
}
// With a second catalog present, a user who prefers it gets translated
// navigation, and anything that catalog lacks stays English.
func TestASecondLanguageActuallyRenders(t *testing.T) {
en, _ := fs.ReadFile(web.FS, "i18n/en.json")
test := fstest.MapFS{
"i18n/en.json": {Data: en},
"i18n/zh-CN.json": {Data: []byte(`{"_meta.name":"简体中文","nav.proxy_hosts":"代理主机","page.proxy_hosts.heading":"代理主机列表","js.copied":"已复制"}`)},
}
b, err := i18n.Load(test, "i18n")
if err != nil {
t.Fatal(err)
}
translations() // make sure the real bundle is loaded first
orig := i18nBundle
i18nBundle = b
t.Cleanup(func() { i18nBundle = orig })
e := newSecEnv(t)
if rec := e.do(t, "alice", http.MethodPost, "/profile", url.Values{"action": {"update_locale"}, "locale": {"zh-CN"}}); !strings.Contains(rec.Header().Get("Location"), "flash=") {
t.Fatalf("choosing zh-CN failed: %s", rec.Header().Get("Location"))
}
body := e.do(t, "alice", http.MethodGet, "/proxy-hosts", nil).Body.String()
for _, want := range []string{`<html lang="zh-CN"`, ">代理主机<", "代理主机列表", `"js.copied":"已复制"`, ">Redirections<"} {
if !strings.Contains(body, want) {
t.Errorf("zh-CN page is missing %q", want)
}
}
// Another user with no choice keeps English.
if other := e.do(t, "admin", http.MethodGet, "/proxy-hosts", nil).Body.String(); !strings.Contains(other, `<html lang="en"`) || strings.Contains(other, "代理主机") {
t.Error("one user's language leaked to another")
}
}
// v2.63.0: the Settings navigation is defined in Go; en.json must carry the
// same English text, or a translated page would show raw keys or stale wording.
func TestSettingsNavigationMatchesTheEnglishCatalog(t *testing.T) {
en := translations().Catalog("en")
for _, sec := range settingsSections {
if got := en["settings.section."+sec.Slug+".label"]; got != sec.Label {
t.Errorf("settings.section.%s.label = %q, Go says %q", sec.Slug, got, sec.Label)
}
if got := en["settings.section."+sec.Slug+".blurb"]; got != sec.Blurb {
t.Errorf("settings.section.%s.blurb = %q, Go says %q", sec.Slug, got, sec.Blurb)
}
}
if got := localizedSettingsSectionLabel("en", "bogus"); got != "Settings" {
t.Errorf("unknown section label → %q", got)
}
}
// v2.63.0: the Dashboard, Profile and Settings › General pages follow the
// chosen language, placeholders included, with untranslated keys left English.
func TestDashboardProfileAndSettingsFollowTheLanguage(t *testing.T) {
en, _ := fs.ReadFile(web.FS, "i18n/en.json")
b, err := i18n.Load(fstest.MapFS{
"i18n/en.json": {Data: en},
"i18n/zh-CN.json": {Data: []byte(`{"_meta.name":"简体中文",
"dashboard.heading":"基础设施概览","js.health.port_disabled":"端口:已禁用",
"profile.heading":"我的资料",
"settings.section.general.label":"常规","settings.section.general.blurb":"站点标题等",
"settings.general.site_title":"站点标题","settings.save_section":"保存{section}设置"}`)},
}, "i18n")
if err != nil {
t.Fatal(err)
}
translations()
orig := i18nBundle
i18nBundle = b
t.Cleanup(func() { i18nBundle = orig })
e := newSecEnv(t)
if rec := e.do(t, "admin", http.MethodPost, "/profile", url.Values{"action": {"update_locale"}, "locale": {"zh-CN"}}); !strings.Contains(rec.Header().Get("Location"), "flash=") {
t.Fatalf("choosing zh-CN failed: %s", rec.Header().Get("Location"))
}
pages := map[string][]string{
"/": {"基础设施概览", `"js.health.port_disabled":"端口:已禁用"`, "Sync Caddy"},
"/profile": {"我的资料", "Display name"},
"/settings/general": {">常规<", "站点标题等", ">站点标题<", "保存常规设置", "Color theme", ">Notifications<"},
}
for path, wants := range pages {
body := e.do(t, "admin", http.MethodGet, path, nil).Body.String()
for _, want := range wants {
if !strings.Contains(body, want) {
t.Errorf("%s in zh-CN is missing %q", path, want)
}
}
for _, raw := range []string{">dashboard.", "\"dashboard.", ">profile.", ">settings.", "\"settings."} {
if strings.Contains(body, raw) {
t.Errorf("%s leaked a raw translation key (%s…)", path, raw)
}
}
}
}