Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions cmd/cmdutil/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cmdutil

import (
"errors"
"fmt"
"testing"

"github.com/patramsey/namecom-cli/internal/api"
)

// IsNotFound is what turns a bare 404 into "domain not found — run 'namecom
// domain list'" across the whole CLI. It matches through wrapping, so the
// wrapped cases matter as much as the direct one: commands routinely add
// context with %w before the error reaches a caller that checks this.
func TestIsNotFound(t *testing.T) {
notFound := &api.APIError{StatusCode: 404, Message: "Domain not found"}

tests := []struct {
name string
err error
want bool
}{
{"nil is not a 404", nil, false},
{"a plain error is not a 404", errors.New("boom"), false},
{"a direct 404", notFound, true},
{"a 404 wrapped once", fmt.Errorf("fetching domain: %w", notFound), true},
{"a 404 wrapped twice", fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", notFound)), true},
{"a 404 behind a UsageError", &UsageError{Err: notFound}, true},
{"403 is not a 404", &api.APIError{StatusCode: 403, Message: "Forbidden"}, false},
{"500 is not a 404", &api.APIError{StatusCode: 500}, false},
{"400 is not a 404", &api.APIError{StatusCode: 400}, false},
{"an unwrapped error mentioning 404 is not a 404", errors.New("got status 404"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNotFound(tt.err); got != tt.want {
t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}

// UsageError marks a problem with how the command was invoked, which the root
// command turns into a different exit code than an API failure. That only
// works if it stays both matchable and unwrappable.
func TestUsageError(t *testing.T) {
inner := errors.New("bad flag combination")
ue := &UsageError{Err: inner}

if got := ue.Error(); got != inner.Error() {
t.Errorf("Error() = %q, want the inner message %q", got, inner.Error())
}
if got := ue.Unwrap(); !errors.Is(got, inner) {
t.Errorf("Unwrap() = %v, want %v", got, inner)
}
if !errors.Is(ue, inner) {
t.Error("errors.Is(UsageError, inner) = false; the chain is broken")
}

var target *UsageError
if !errors.As(fmt.Errorf("context: %w", ue), &target) {
t.Error("a wrapped UsageError should still be findable with errors.As")
}
}

// NewUsageError returns nil for nil so callers can apply it straight to a
// function result. Returning a non-nil *UsageError wrapping nil would make
// `if err != nil` true on success — the classic typed-nil trap.
func TestNewUsageError(t *testing.T) {
if err := NewUsageError(nil); err != nil {
t.Errorf("NewUsageError(nil) = %v, want nil", err)
}

inner := errors.New("boom")
err := NewUsageError(inner)
if err == nil {
t.Fatal("NewUsageError(non-nil) = nil, want an error")
}
var ue *UsageError
if !errors.As(err, &ue) {
t.Fatalf("NewUsageError returned %T, want a *UsageError", err)
}
if !errors.Is(err, inner) {
t.Error("NewUsageError should preserve the original error in the chain")
}
}
6 changes: 0 additions & 6 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,6 @@ func initContext(cmd *cobra.Command) error {
return nil
}

// IsYes reports whether --yes / -y was set globally (skip confirmation).
func IsYes() bool { return gf.yes }

// IsDryRun reports whether --dry-run was set globally.
func IsDryRun() bool { return gf.dryRun }

// skipClientInit returns true for commands that don't need API credentials.
func skipClientInit(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
Expand Down
124 changes: 124 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,127 @@ func TestSuccess_StructuredFormats(t *testing.T) {
}
})
}

// ---- flag value parsing -----------------------------------------------------

// ParseFormat and ParseColorMode validate --output and --color. Both lowercase
// their input, so mixed case must be accepted — nothing previously proved that,
// and `--output JSON` silently erroring would be a poor way to find out.
func TestParseFormat(t *testing.T) {
tests := []struct {
in string
want Format
wantErr bool
}{
{"table", FormatTable, false},
{"json", FormatJSON, false},
{"yaml", FormatYAML, false},
{"JSON", FormatJSON, false},
{"YaMl", FormatYAML, false},
{"TABLE", FormatTable, false},
{"", "", true},
{"xml", "", true},
{"jsonl", "", true},
{" json", "", true}, // not trimmed: a stray space is a real mistake, not a synonym
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, err := ParseFormat(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("ParseFormat(%q) = %q, want an error", tt.in, got)
}
// The message has to name the valid choices; "unknown format"
// alone leaves the user guessing.
for _, want := range []string{"table", "json", "yaml"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q should list %q as a valid choice", err, want)
}
}
return
}
if err != nil {
t.Fatalf("ParseFormat(%q) unexpected error: %v", tt.in, err)
}
if got != tt.want {
t.Errorf("ParseFormat(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

func TestParseColorMode(t *testing.T) {
tests := []struct {
in string
want ColorMode
wantErr bool
}{
{"auto", ColorAuto, false},
{"always", ColorAlways, false},
{"never", ColorNever, false},
{"ALWAYS", ColorAlways, false},
{"Never", ColorNever, false},
{"", "", true},
{"yes", "", true},
{"true", "", true},
{"none", "", true},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, err := ParseColorMode(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("ParseColorMode(%q) = %q, want an error", tt.in, got)
}
for _, want := range []string{"auto", "always", "never"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q should list %q as a valid choice", err, want)
}
}
return
}
if err != nil {
t.Fatalf("ParseColorMode(%q) unexpected error: %v", tt.in, err)
}
if got != tt.want {
t.Errorf("ParseColorMode(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

// ---- color helpers ----------------------------------------------------------

// Red and Amber must return the text unchanged when color is off. A helper that
// emits escape sequences regardless would corrupt piped output and any file
// written with --debug-file.
func TestRedAmber_NoColorReturnsPlainText(t *testing.T) {
c := noColor()
for _, tt := range []struct {
name string
got string
}{
{"Red", c.Red("failed")},
{"Amber", c.Amber("careful")},
} {
if strings.ContainsRune(tt.got, '\x1b') {
t.Errorf("%s with color disabled returned an escape sequence: %q", tt.name, tt.got)
}
}
if got := c.Red("failed"); got != "failed" {
t.Errorf("Red = %q, want %q unchanged", got, "failed")
}
if got := c.Amber("careful"); got != "careful" {
t.Errorf("Amber = %q, want %q unchanged", got, "careful")
}
}

func TestRedAmber_ColorWrapsButPreservesText(t *testing.T) {
c := &Config{Color: ColorAlways}
if got := c.Red("failed"); !strings.Contains(got, "failed") {
t.Errorf("Red = %q, want it to still contain %q", got, "failed")
}
if got := c.Amber("careful"); !strings.Contains(got, "careful") {
t.Errorf("Amber = %q, want it to still contain %q", got, "careful")
}
}
Loading