Files
upstream-mattermost/tools/mmgotool/commands/i18n_verify_test.go
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

345 lines
12 KiB
Go

// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package commands
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeCatalog writes one locale file into dir and returns its path.
func writeCatalog(t *testing.T, dir, name, body string) string {
t.Helper()
p := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(p, []byte(body), 0600))
return p
}
// source parses an en.json body into the items a catalog is checked against.
func source(t *testing.T, en string) map[string]Item {
t.Helper()
items, err := loadItems([]byte(en))
require.NoError(t, err)
return items
}
func TestVerifyLocale(t *testing.T) {
t.Parallel()
// A pluralized source and a plain source, so each case can pick the shape
// it needs without restating the catalog.
const enPlural = `[{"id":"a.b","translation":{"one":"{{.User}} is here","other":"{{.User}} are here"}}]`
const enPlain = `[{"id":"a.b","translation":"{{.User}} is here"}]`
testCases := []struct {
name string
localeName string
en string
locale string
warnMissingIDs bool
problem string // substring expected in problems, "" for none
warning string // substring expected in warnings, "" for none
}{
{
name: "clean plain catalog",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b","translation":"{{.User}} est ici"}]`,
},
{
name: "clean pluralized catalog",
localeName: "fr.json",
en: enPlural,
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":"{{.User}} sont ici"}}]`,
},
// The runtime loader is the first gate: whatever it rejects at server
// startup has to be rejected here.
{
name: "loader rejects invalid JSON",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b",`,
problem: "rejected by the runtime translation loader",
},
{
name: "loader rejects an unparseable template",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b","translation":"{{.User est ici"}]`,
problem: "rejected by the runtime translation loader",
},
// Id parity.
{
name: "missing id is an error by default",
localeName: "fr.json",
en: enPlain,
locale: `[]`,
problem: "a.b: missing id",
},
{
name: "missing id is a warning under warn-missing-ids",
localeName: "fr.json",
en: enPlain,
locale: `[]`,
warnMissingIDs: true,
warning: "a.b: missing id",
},
{
name: "extra id not in en.json",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b","translation":"{{.User}} est ici"},{"id":"z.z","translation":"orphelin"}]`,
problem: "z.z: extra id not in en.json",
},
// Template tokens. An unknown one renders "<no value>"; a dropped one
// quietly loses the value it was meant to show.
{
name: "unknown template token",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b","translation":"{{.User}} est ici, {{.Extra}}"}]`,
problem: `unknown template token {{.Extra}} not present in source`,
},
{
name: "dropped template token",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b","translation":"quelqu'un est ici"}]`,
problem: `source template token {{.User}} is missing from the translation`,
},
{
name: "a token in any plural form counts as present",
localeName: "fr.json",
en: enPlural,
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":"ils sont ici"}}]`,
},
// Plural shape against en.json.
{
name: "demoting a pluralized source to a single string",
localeName: "fr.json",
en: enPlural,
locale: `[{"id":"a.b","translation":"{{.User}} sont ici"}]`,
problem: "en.json pluralises this id but the translation is a single string",
},
{
name: "promoting a plain source to a plural is allowed",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":"{{.User}} sont ici"}}]`,
},
{
name: "categories are checked on a promoted plural too",
localeName: "fr.json",
en: enPlain,
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","many":"x","other":"{{.User}} sont ici"}}]`,
problem: `plural category "many" is not used by this locale`,
},
// CLDR categories for the locale.
{
name: "missing a category the locale requires",
localeName: "ro.json",
en: enPlural,
locale: `[{"id":"a.b","translation":{"one":"{{.User}} e aici","other":"{{.User}} sunt aici"}}]`,
problem: `missing plural category "few"`,
},
{
name: "a category the locale does not use",
localeName: "ja.json",
en: enPlural,
locale: `[{"id":"a.b","translation":{"one":"{{.User}}","other":"{{.User}}"}}]`,
problem: `plural category "one" is not used by this locale`,
},
// An empty form renders the raw translation id, so it is worse than no
// translation at all.
{
name: "empty plural form",
localeName: "fr.json",
en: enPlural,
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":""}}]`,
problem: `plural category "other" is empty, which renders the raw translation id`,
},
{
name: "whitespace-only plural form",
localeName: "fr.json",
en: enPlural,
locale: `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":" "}}]`,
problem: `plural category "other" is empty, which renders the raw translation id`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
problems, warnings := verifyLocale(tc.localeName, []byte(tc.locale), source(t, tc.en), tc.warnMissingIDs)
if tc.problem == "" {
assert.Empty(t, problems, "expected no problems")
} else {
assert.Contains(t, strings.Join(problems, "\n"), tc.problem)
}
if tc.warning == "" {
assert.Empty(t, warnings, "expected no warnings")
} else {
assert.Contains(t, strings.Join(warnings, "\n"), tc.warning)
}
})
}
}
// The runtime loader rejects these before any of the catalog checks run, which
// is what makes the "no CLDR plural spec" branch unreachable: it derives the
// language with language.Parse, which only yields one when a plural spec exists
// for the same tag.
func TestVerifyLocaleUnknownLocale(t *testing.T) {
t.Parallel()
const catalog = `[{"id":"a.b","translation":"hi"}]`
for _, name := range []string{"xx.json", "notes.json", "README.json"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
problems, _ := verifyLocale(name, []byte(catalog), source(t, catalog), false)
assert.Contains(t, strings.Join(problems, "\n"), "rejected by the runtime translation loader")
})
}
}
// Each file is loaded into its own bundle, so a defect in one locale can never
// be masked by another having already been loaded, in either order.
func TestVerifyLocaleIsOrderIndependent(t *testing.T) {
t.Parallel()
const en = `[{"id":"a.b","translation":{"one":"{{.User}} is here","other":"{{.User}} are here"}}]`
const good = `[{"id":"a.b","translation":{"one":"{{.User}} ist da","other":"{{.User}} sind da"}}]`
const bad = `[{"id":"a.b","translation":{"one":"{{.User}} est ici","other":""}}]`
items := source(t, en)
badFirst, _ := verifyLocale("fr.json", []byte(bad), items, false)
_, _ = verifyLocale("de.json", []byte(good), items, false)
badSecond, _ := verifyLocale("fr.json", []byte(bad), items, false)
assert.NotEmpty(t, badFirst)
assert.Equal(t, badFirst, badSecond, "the same file must report the same problems regardless of what was checked before it")
}
func TestVerifyCmd(t *testing.T) {
t.Parallel()
// setup builds a server-dir whose i18n/ holds en.json, one locale, and the
// entries the walk is meant to ignore.
setup := func(t *testing.T, locale string) string {
t.Helper()
serverDir := t.TempDir()
dir := filepath.Join(serverDir, "i18n")
require.NoError(t, os.MkdirAll(dir, 0700))
writeCatalog(t, dir, "en.json", `[{"id":"a.b","translation":"{{.User}} is here"}]`)
writeCatalog(t, dir, "fr.json", locale)
// Neither of these is a locale catalog, and neither may be walked.
writeCatalog(t, dir, "README.md", "not a catalog")
require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0700))
writeCatalog(t, filepath.Join(dir, "nested"), "de.json", `[{"id":"nope","translation":"x"}]`)
return serverDir
}
run := func(t *testing.T, serverDir string, args ...string) error {
t.Helper()
cmd := *VerifyCmd
cmd.SetArgs(args)
cmd.SetOut(os.NewFile(0, os.DevNull))
return verifyCmdF(&cmd, nil)
}
t.Run("a clean catalog passes, and non-catalog entries are skipped", func(t *testing.T) {
t.Parallel()
serverDir := setup(t, `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
// A walked README.md or nested/de.json would surface as an extra id.
assert.NoError(t, run(t, serverDir))
})
t.Run("a defect fails the command", func(t *testing.T) {
serverDir := setup(t, `[{"id":"a.b","translation":"{{.Nope}} est ici"}]`)
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
err := run(t, serverDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "error(s) across 1 locale files")
})
t.Run("a stray json file is reported", func(t *testing.T) {
serverDir := setup(t, `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
writeCatalog(t, filepath.Join(serverDir, "i18n"), "notes.json", "{not even json")
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
err := run(t, serverDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "error(s) across 2 locale files")
})
t.Run("a source catalog the loader rejects fails the command", func(t *testing.T) {
serverDir := t.TempDir()
dir := filepath.Join(serverDir, "i18n")
require.NoError(t, os.MkdirAll(dir, 0700))
writeCatalog(t, dir, "en.json", `[{"id":"a.b","translation":"{{.User is here"}]`)
writeCatalog(t, dir, "fr.json", `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
err := run(t, serverDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "rejected by the runtime translation loader")
})
// The symlink target is a loadable catalog, so root is the only thing that
// can fail this.
t.Run("a catalog symlinked out of the translation directory is refused", func(t *testing.T) {
serverDir := setup(t, `[{"id":"a.b","translation":"{{.User}} est ici"}]`)
outside := writeCatalog(t, t.TempDir(), "de.json", `[{"id":"a.b","translation":"{{.User}} ist da"}]`)
require.NoError(t, os.Symlink(outside, filepath.Join(serverDir, "i18n", "de.json")))
require.NoError(t, VerifyCmd.Flags().Set("server-dir", serverDir))
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
err := run(t, serverDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "error(s) across 2 locale files")
})
t.Run("a missing translation directory is reported against the path", func(t *testing.T) {
require.NoError(t, VerifyCmd.Flags().Set("server-dir", filepath.Join(t.TempDir(), "nope")))
require.NoError(t, VerifyCmd.Flags().Set("warn-missing-ids", "false"))
err := run(t, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to open translation directory")
})
}