diff --git a/go.mod b/go.mod index 9750eb3..c0bad03 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( charm.land/lipgloss/v2 v2.0.0 github.com/mergestat/timediff v0.0.4 github.com/shopspring/decimal v1.4.0 + github.com/stretchr/testify v1.11.1 github.com/sumup/sumup-go v0.15.0 github.com/urfave/cli/v3 v3.6.2 golang.org/x/term v0.40.0 @@ -23,12 +24,15 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a6823e7..9fba983 100644 --- a/go.sum +++ b/go.sum @@ -60,5 +60,7 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/context_internal_test.go b/internal/app/context_internal_test.go new file mode 100644 index 0000000..f4d3a9c --- /dev/null +++ b/internal/app/context_internal_test.go @@ -0,0 +1,30 @@ +package app + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeLocale(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "returns empty string for empty input", input: "", want: ""}, + {name: "trims whitespace and replaces underscore", input: " en_US.UTF-8 ", want: "en-US"}, + {name: "strips locale modifier", input: "sr_RS@latin", want: "sr-RS"}, + {name: "returns plain locale unchanged", input: "de-DE", want: "de-DE"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, normalizeLocale(tt.input)) + }) + } +} diff --git a/internal/app/context_test.go b/internal/app/context_test.go index 8ff0bf4..248347d 100644 --- a/internal/app/context_test.go +++ b/internal/app/context_test.go @@ -1,123 +1,68 @@ -package app +package app_test import ( "context" - "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" + "github.com/sumup/sumup-cli/internal/app" "github.com/sumup/sumup-cli/internal/config" ) -func TestNormalizeLocale(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - }{ - {name: "empty", input: "", want: ""}, - {name: "trim and replace underscore", input: " en_US.UTF-8 ", want: "en-US"}, - {name: "strip modifier", input: "sr_RS@latin", want: "sr-RS"}, - {name: "plain locale", input: "de-DE", want: "de-DE"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - if got := normalizeLocale(tc.input); got != tc.want { - t.Fatalf("normalizeLocale(%q) = %q, want %q", tc.input, got, tc.want) - } - }) - } -} - -func TestGetMerchantCodePrefersFlag(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tempDir) - t.Setenv("HOME", tempDir) - if err := config.SetCurrentMerchantCode("MCONFIG"); err != nil { - t.Fatalf("set current merchant code: %v", err) - } - - cmd := &cli.Command{ - Name: "sumup", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "merchant-code"}, - }, - Action: func(_ context.Context, cmd *cli.Command) error { - got, err := GetMerchantCode(cmd, "merchant-code") - if err != nil { - t.Fatalf("GetMerchantCode() unexpected error: %v", err) - } - if got != "MFLAG" { - t.Fatalf("GetMerchantCode() = %q, want %q", got, "MFLAG") - } - return nil - }, - } - - if err := cmd.Run(context.Background(), []string{"sumup", "--merchant-code", "MFLAG"}); err != nil { - t.Fatalf("run command: %v", err) - } +func TestGetMerchantCode(t *testing.T) { + t.Run("prefers flag value over stored config", func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + require.NoError(t, config.SetCurrentMerchantCode("MCONFIG")) + + got, err := runGetMerchantCode(t, []string{"sumup", "--merchant-code", "MFLAG"}) + require.NoError(t, err) + assert.Equal(t, "MFLAG", got) + }) + + t.Run("falls back to stored config", func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + require.NoError(t, config.SetCurrentMerchantCode("MCONFIG")) + + got, err := runGetMerchantCode(t, []string{"sumup"}) + require.NoError(t, err) + assert.Equal(t, "MCONFIG", got) + }) + + t.Run("returns helpful error when no merchant code is available", func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + got, err := runGetMerchantCode(t, []string{"sumup"}) + require.Error(t, err) + assert.Empty(t, got) + assert.ErrorContains(t, err, "merchant code is required") + }) } -func TestGetMerchantCodeFallsBackToConfig(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tempDir) - t.Setenv("HOME", tempDir) - if err := config.SetCurrentMerchantCode("MCONFIG"); err != nil { - t.Fatalf("set current merchant code: %v", err) - } +func runGetMerchantCode(t *testing.T, args []string) (string, error) { + t.Helper() + var got string cmd := &cli.Command{ Name: "sumup", Flags: []cli.Flag{ &cli.StringFlag{Name: "merchant-code"}, }, Action: func(_ context.Context, cmd *cli.Command) error { - got, err := GetMerchantCode(cmd, "merchant-code") - if err != nil { - t.Fatalf("GetMerchantCode() unexpected error: %v", err) - } - if got != "MCONFIG" { - t.Fatalf("GetMerchantCode() = %q, want %q", got, "MCONFIG") - } - return nil + var err error + got, err = app.GetMerchantCode(cmd, "merchant-code") + return err }, } - if err := cmd.Run(context.Background(), []string{"sumup"}); err != nil { - t.Fatalf("run command: %v", err) - } -} - -func TestGetMerchantCodeErrorsWhenUnset(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tempDir) - t.Setenv("HOME", tempDir) - - cmd := &cli.Command{ - Name: "sumup", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "merchant-code"}, - }, - Action: func(_ context.Context, cmd *cli.Command) error { - _, err := GetMerchantCode(cmd, "merchant-code") - if err == nil { - t.Fatal("GetMerchantCode() error = nil, want non-nil") - } - if !strings.Contains(err.Error(), "merchant code is required") { - t.Fatalf("GetMerchantCode() error = %q, want merchant code hint", err) - } - return nil - }, - } - - if err := cmd.Run(context.Background(), []string{"sumup"}); err != nil { - t.Fatalf("run command: %v", err) - } + err := cmd.Run(context.Background(), args) + return got, err } diff --git a/internal/commands/util/util_test.go b/internal/commands/util/util_test.go index 8830b12..467106d 100644 --- a/internal/commands/util/util_test.go +++ b/internal/commands/util/util_test.go @@ -1,14 +1,16 @@ -package util +package util_test import ( "context" - "strings" "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "github.com/sumup/sumup-cli/internal/app" + "github.com/sumup/sumup-cli/internal/commands/util" ) func TestRequireSingleArg(t *testing.T) { @@ -25,37 +27,20 @@ func TestRequireSingleArg(t *testing.T) { {name: "too many args", args: []string{"sumup", "abc", "def"}, wantErr: "unexpected extra arguments"}, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { t.Parallel() - cmd := &cli.Command{ - Name: "sumup", - Action: func(_ context.Context, cmd *cli.Command) error { - got, err := RequireSingleArg(cmd, "value") - if tc.wantErr != "" { - if err == nil { - t.Fatalf("RequireSingleArg() error = nil, want %q", tc.wantErr) - } - if !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("RequireSingleArg() error = %q, want substring %q", err, tc.wantErr) - } - return nil - } - - if err != nil { - t.Fatalf("RequireSingleArg() unexpected error: %v", err) - } - if got != tc.want { - t.Fatalf("RequireSingleArg() = %q, want %q", got, tc.want) - } - return nil - }, + got, err := runRequireSingleArg(t, tt.args) + if tt.wantErr != "" { + require.Error(t, err) + assert.Empty(t, got) + assert.ErrorContains(t, err, tt.wantErr) + return } - if err := cmd.Run(context.Background(), tc.args); err != nil { - t.Fatalf("run command: %v", err) - } + require.NoError(t, err) + assert.Equal(t, tt.want, got) }) } } @@ -64,54 +49,74 @@ func TestStringOrDefault(t *testing.T) { t.Parallel() fallback := "fallback" - if got := StringOrDefault(nil, fallback); got != fallback { - t.Fatalf("StringOrDefault(nil) = %q, want %q", got, fallback) - } - empty := "" - if got := StringOrDefault(&empty, fallback); got != fallback { - t.Fatalf("StringOrDefault(empty) = %q, want %q", got, fallback) - } + t.Run("returns fallback for nil", func(t *testing.T) { + assert.Equal(t, fallback, util.StringOrDefault(nil, fallback)) + }) - value := "value" - if got := StringOrDefault(&value, fallback); got != value { - t.Fatalf("StringOrDefault(value) = %q, want %q", got, value) - } + t.Run("returns fallback for empty string", func(t *testing.T) { + empty := "" + assert.Equal(t, fallback, util.StringOrDefault(&empty, fallback)) + }) + + t.Run("returns provided value when present", func(t *testing.T) { + value := "value" + assert.Equal(t, value, util.StringOrDefault(&value, fallback)) + }) } func TestBoolLabel(t *testing.T) { t.Parallel() - if got := BoolLabel(nil); got != "-" { - t.Fatalf("BoolLabel(nil) = %q, want %q", got, "-") - } + t.Run("returns dash for nil", func(t *testing.T) { + assert.Equal(t, "-", util.BoolLabel(nil)) + }) - trueValue := true - if got := BoolLabel(&trueValue); got != "Yes" { - t.Fatalf("BoolLabel(true) = %q, want %q", got, "Yes") - } + t.Run("returns yes for true", func(t *testing.T) { + trueValue := true + assert.Equal(t, "Yes", util.BoolLabel(&trueValue)) + }) - falseValue := false - if got := BoolLabel(&falseValue); got != "No" { - t.Fatalf("BoolLabel(false) = %q, want %q", got, "No") - } + t.Run("returns no for false", func(t *testing.T) { + falseValue := false + assert.Equal(t, "No", util.BoolLabel(&falseValue)) + }) } func TestTimeOrDash(t *testing.T) { t.Parallel() - if got := TimeOrDash(nil, nil); got != "-" { - t.Fatalf("TimeOrDash(nil, nil) = %q, want %q", got, "-") - } - ts := time.Date(2026, time.March, 26, 12, 34, 56, 0, time.UTC) - ctx := &app.Context{ExactTimestamps: true} - if got := TimeOrDash(ctx, &ts); got != ts.In(time.Local).Format(time.RFC3339) { - t.Fatalf("TimeOrDash(exact) = %q, want %q", got, ts.In(time.Local).Format(time.RFC3339)) - } - relative := TimeOrDash(&app.Context{Locale: "en"}, &ts) - if relative == "" || relative == "-" { - t.Fatalf("TimeOrDash(relative) = %q, want non-empty relative string", relative) + t.Run("returns dash for nil timestamp", func(t *testing.T) { + assert.Equal(t, "-", util.TimeOrDash(nil, nil)) + }) + + t.Run("formats exact timestamps in local time", func(t *testing.T) { + ctx := &app.Context{ExactTimestamps: true} + assert.Equal(t, ts.In(time.Local).Format(time.RFC3339), util.TimeOrDash(ctx, &ts)) + }) + + t.Run("formats relative timestamps when exact mode is disabled", func(t *testing.T) { + got := util.TimeOrDash(&app.Context{Locale: "en"}, &ts) + assert.NotEmpty(t, got) + assert.NotEqual(t, "-", got) + }) +} + +func runRequireSingleArg(t *testing.T, args []string) (string, error) { + t.Helper() + + var got string + cmd := &cli.Command{ + Name: "sumup", + Action: func(_ context.Context, cmd *cli.Command) error { + var err error + got, err = util.RequireSingleArg(cmd, "value") + return err + }, } + + err := cmd.Run(context.Background(), args) + return got, err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1af05b6..822601f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,37 +1,38 @@ -package config +package config_test -import "testing" +import ( + "testing" -func TestConfigSaveAndLoadRoundTrip(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tempDir) - t.Setenv("HOME", tempDir) + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - cfg := &Config{CurrentMerchantCode: "M123"} - if err := cfg.Save(); err != nil { - t.Fatalf("Save() error = %v", err) - } + "github.com/sumup/sumup-cli/internal/config" +) - loaded, err := Load() - if err != nil { - t.Fatalf("Load() error = %v", err) - } +func TestConfig_Save(t *testing.T) { + t.Run("persists config that can be loaded back", func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) - if loaded.CurrentMerchantCode != cfg.CurrentMerchantCode { - t.Fatalf("Load().CurrentMerchantCode = %q, want %q", loaded.CurrentMerchantCode, cfg.CurrentMerchantCode) - } + cfg := &config.Config{CurrentMerchantCode: "M123"} + + require.NoError(t, cfg.Save()) + + loaded, err := config.Load() + require.NoError(t, err) + assert.Equal(t, cfg.CurrentMerchantCode, loaded.CurrentMerchantCode) + }) } -func TestLoadMissingConfigReturnsEmptyConfig(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tempDir) - t.Setenv("HOME", tempDir) - - cfg, err := Load() - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.CurrentMerchantCode != "" { - t.Fatalf("Load().CurrentMerchantCode = %q, want empty", cfg.CurrentMerchantCode) - } +func TestLoad(t *testing.T) { + t.Run("returns empty config when file does not exist", func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tempDir) + t.Setenv("HOME", tempDir) + + cfg, err := config.Load() + require.NoError(t, err) + assert.Empty(t, cfg.CurrentMerchantCode) + }) }