From ceede4ea5977871016dfd2525a6539b3253df4af Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 28 May 2026 12:14:42 +0800 Subject: [PATCH] test(i18n): close auth config validation gaps --- .github/workflows/test.yml | 3 ++ cmd/auth/auth.go | 35 ++++++++++---- cmd/auth/auth_test.go | 83 ++++++++++++++++++++++++++++++++ cmd/config/config.go | 5 +- cmd/config/config_test.go | 32 ++++++++++++ docs/i18n.md | 7 +++ internal/i18n/locales/en-US.json | 2 + internal/i18n/locales/zh-CN.json | 2 + 8 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 cmd/auth/auth_test.go create mode 100644 cmd/config/config_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f86757..3d5673c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,5 +23,8 @@ jobs: - name: Validate i18n messages run: go run ./internal/i18n/cmd/check + - name: Scan i18n key references + run: go run ./internal/i18n/cmd/check --scan-code + - name: Run Go tests run: go test ./... diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 13ca350..bcbe27d 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -7,7 +7,6 @@ import ( "io" "os" "strings" - "syscall" "github.com/spf13/cobra" "golang.org/x/term" @@ -18,6 +17,11 @@ import ( const envTokenVar = "GITLINK_TOKEN" +var ( + storeToken = internalAuth.StoreToken + loadToken = internalAuth.LoadToken +) + func NewAuthCmd(tr *i18n.Translator) *cobra.Command { if tr == nil { tr = i18n.Default() @@ -42,22 +46,21 @@ func newLoginCmd(tr *i18n.Translator) *cobra.Command { if tokenMode { return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr) } - return loginWithPassword(cmd.OutOrStdout(), tr) + return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr) }, } cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token")) return cmd } -func loginWithPassword(out io.Writer, tr *i18n.Translator) error { - reader := bufio.NewReader(os.Stdin) - +func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error { + reader := bufio.NewReader(in) fmt.Fprint(out, tr.T("prompt.auth.username")) username, _ := reader.ReadString('\n') username = strings.TrimSpace(username) fmt.Fprint(out, tr.T("prompt.auth.password")) - passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + passwordBytes, err := readPassword(in, reader) if err != nil { return fmt.Errorf("failed to read password: %w", err) } @@ -73,6 +76,20 @@ func loginWithPassword(out io.Writer, tr *i18n.Translator) error { return nil } +func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) { + if file, ok := in.(*os.File); ok { + fd := int(file.Fd()) + if term.IsTerminal(fd) { + return term.ReadPassword(fd) + } + } + password, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return nil, err + } + return []byte(strings.TrimRight(password, "\r\n")), nil +} + func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error { reader := bufio.NewReader(in) fmt.Fprint(out, tr.T("prompt.auth.token")) @@ -83,7 +100,7 @@ func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error { return errors.New(tr.T("error.auth.token_empty")) } - if err := internalAuth.StoreToken(token); err != nil { + if err := storeToken(token); err != nil { return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()})) } @@ -97,7 +114,7 @@ func newLogoutCmd(tr *i18n.Translator) *cobra.Command { Short: tr.T("cmd.auth.logout.short"), RunE: func(cmd *cobra.Command, args []string) error { if err := internalAuth.DeleteToken(); err != nil { - return fmt.Errorf("failed to delete token: %w", err) + return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()})) } fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out")) return nil @@ -115,7 +132,7 @@ func newStatusCmd(tr *i18n.Translator) *cobra.Command { fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})) } - token, err := internalAuth.LoadToken() + token, err := loadToken() if err != nil || token == "" { if os.Getenv(envTokenVar) == "" { fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.not_logged_in")) diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go new file mode 100644 index 0000000..681a748 --- /dev/null +++ b/cmd/auth/auth_test.go @@ -0,0 +1,83 @@ +package auth + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func TestAuthTokenLoginUsesLocalizedPrompt(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + oldStoreToken := storeToken + var storedToken string + storeToken = func(token string) error { + storedToken = token + return nil + } + t.Cleanup(func() { + storeToken = oldStoreToken + }) + + cmd := NewAuthCmd(tr) + cmd.SetArgs([]string{"login", "--token"}) + cmd.SetIn(strings.NewReader("test-token\n")) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if storedToken != "test-token" { + t.Fatalf("stored token = %q, want test-token", storedToken) + } + output := out.String() + if !strings.Contains(output, "粘贴你的访问 Token") { + t.Fatalf("expected localized token prompt, got:\n%s", output) + } + if !strings.Contains(output, "Token 已保存") { + t.Fatalf("expected localized token saved message, got:\n%s", output) + } +} + +func TestAuthStatusUsesLocalizedNotLoggedIn(t *testing.T) { + t.Setenv("GITLINK_CONFIG_DIR", t.TempDir()) + t.Setenv(envTokenVar, "") + + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + oldLoadToken := loadToken + loadToken = func() (string, error) { + return "", os.ErrNotExist + } + t.Cleanup(func() { + loadToken = oldLoadToken + }) + + cmd := NewAuthCmd(tr) + cmd.SetArgs([]string{"status"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + output := out.String() + if !strings.Contains(output, "未登录") { + t.Fatalf("expected localized not logged in message, got:\n%s", output) + } + if !strings.Contains(output, "gitlink-cli auth login") { + t.Fatalf("expected login hint, got:\n%s", output) + } +} diff --git a/cmd/config/config.go b/cmd/config/config.go index c21abc8..93e1efa 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -49,7 +49,10 @@ func newSetCmd(tr *i18n.Translator) *cobra.Command { if err := internalConfig.Set(args[0], args[1]); err != nil { return err } - fmt.Printf("✓ %s = %s\n", args[0], args[1]) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{ + "key": args[0], + "value": args[1], + })) return nil }, } diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go new file mode 100644 index 0000000..564e732 --- /dev/null +++ b/cmd/config/config_test.go @@ -0,0 +1,32 @@ +package config + +import ( + "bytes" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func TestConfigSetUsesCommandOutputAndI18n(t *testing.T) { + t.Setenv("GITLINK_CONFIG_DIR", t.TempDir()) + + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cmd := NewConfigCmd(tr) + cmd.SetArgs([]string{"set", "lang", "zh-CN"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + output := out.String() + if !strings.Contains(output, "已设置 lang = zh-CN") { + t.Fatalf("expected localized config set output, got:\n%s", output) + } +} diff --git a/docs/i18n.md b/docs/i18n.md index ee53fbc..23efe57 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -55,6 +55,13 @@ Do not invent numbered keys such as `msg001`. Prefer names that describe ownersh Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON. +Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight: + +- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`. +- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code. +- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them. +- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages. + ## Runtime Access Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8ba55c..30b6274 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -80,6 +80,7 @@ "cmd.webhook.test.short": "Trigger a test delivery for a webhook", "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", "cmd.webhook.view.short": "View webhook details", + "error.auth.delete_token_failed": "failed to delete token: {message}", "error.auth.login_failed": "login failed: {message}", "error.auth.store_token_failed": "failed to store token: {message}", "error.auth.token_empty": "token cannot be empty", @@ -178,6 +179,7 @@ "success.auth.logged_out": "✓ Logged out", "success.auth.token_saved": "✓ Token saved", "success.config.initialized": "✓ Config initialized at {path}", + "success.config.set": "✓ {key} = {value}", "warning.auth.not_logged_in": "✗ Not logged in", "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b70298c..bd1a7ed 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -80,6 +80,7 @@ "cmd.webhook.test.short": "触发 Webhook 测试投递", "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.view.short": "查看 Webhook 详情", + "error.auth.delete_token_failed": "删除 Token 失败:{message}", "error.auth.login_failed": "登录失败:{message}", "error.auth.store_token_failed": "保存 Token 失败:{message}", "error.auth.token_empty": "Token 不能为空", @@ -178,6 +179,7 @@ "success.auth.logged_out": "✓ 已退出登录", "success.auth.token_saved": "✓ Token 已保存", "success.config.initialized": "✓ 配置已初始化:{path}", + "success.config.set": "✓ 已设置 {key} = {value}", "warning.auth.not_logged_in": "✗ 未登录", "warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})", "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)"