diff --git a/cmd/pulse-host-agent/main_test.go b/cmd/pulse-host-agent/main_test.go new file mode 100644 index 0000000..9b1b06e --- /dev/null +++ b/cmd/pulse-host-agent/main_test.go @@ -0,0 +1,467 @@ +package main + +import ( + "reflect" + "strings" + "testing" + + "github.com/rs/zerolog" +) + +func TestGatherTags(t *testing.T) { + tests := []struct { + name string + env string + flags []string + expected []string + }{ + // Empty inputs + { + name: "empty env and flags returns empty slice", + env: "", + flags: nil, + expected: []string{}, + }, + { + name: "empty env and empty flags returns empty slice", + env: "", + flags: []string{}, + expected: []string{}, + }, + + // Environment only + { + name: "single env tag", + env: "prod", + flags: nil, + expected: []string{"prod"}, + }, + { + name: "multiple env tags comma separated", + env: "prod,us-west", + flags: nil, + expected: []string{"prod", "us-west"}, + }, + { + name: "env tags with whitespace trimmed", + env: " prod , us-west ", + flags: nil, + expected: []string{"prod", "us-west"}, + }, + { + name: "env empty items filtered", + env: "prod,,us-west,", + flags: nil, + expected: []string{"prod", "us-west"}, + }, + { + name: "env whitespace-only items filtered", + env: "prod, ,us-west", + flags: nil, + expected: []string{"prod", "us-west"}, + }, + + // Flags only + { + name: "single flag tag", + env: "", + flags: []string{"staging"}, + expected: []string{"staging"}, + }, + { + name: "multiple flag tags", + env: "", + flags: []string{"staging", "eu-central"}, + expected: []string{"staging", "eu-central"}, + }, + { + name: "flag tags with whitespace trimmed", + env: "", + flags: []string{" staging ", " eu-central "}, + expected: []string{"staging", "eu-central"}, + }, + { + name: "flag empty items filtered", + env: "", + flags: []string{"staging", "", "eu-central"}, + expected: []string{"staging", "eu-central"}, + }, + { + name: "flag whitespace-only items filtered", + env: "", + flags: []string{"staging", " ", "eu-central"}, + expected: []string{"staging", "eu-central"}, + }, + + // Both env and flags (env first, then flags) + { + name: "env tags come before flags", + env: "prod", + flags: []string{"app1"}, + expected: []string{"prod", "app1"}, + }, + { + name: "multiple env and multiple flags", + env: "prod,us-west", + flags: []string{"app1", "critical"}, + expected: []string{"prod", "us-west", "app1", "critical"}, + }, + { + name: "duplicates preserved (no dedup)", + env: "prod,prod", + flags: []string{"prod"}, + expected: []string{"prod", "prod", "prod"}, + }, + + // Edge cases + { + name: "only commas in env", + env: ",,,", + flags: nil, + expected: []string{}, + }, + { + name: "single comma", + env: ",", + flags: nil, + expected: []string{}, + }, + { + name: "env with tabs", + env: "\tprod\t,\tstaging\t", + flags: nil, + expected: []string{"prod", "staging"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gatherTags(tt.env, tt.flags) + if !reflect.DeepEqual(got, tt.expected) { + t.Fatalf("expected %v, got %v", tt.expected, got) + } + }) + } +} + +func TestParseLogLevel(t *testing.T) { + tests := []struct { + name string + input string + wantLevel zerolog.Level + wantErr bool + errSubstr string + }{ + // Valid levels + { + name: "debug level", + input: "debug", + wantLevel: zerolog.DebugLevel, + }, + { + name: "info level", + input: "info", + wantLevel: zerolog.InfoLevel, + }, + { + name: "warn level", + input: "warn", + wantLevel: zerolog.WarnLevel, + }, + { + name: "error level", + input: "error", + wantLevel: zerolog.ErrorLevel, + }, + + // Case insensitivity + { + name: "uppercase DEBUG", + input: "DEBUG", + wantLevel: zerolog.DebugLevel, + }, + { + name: "mixed case Info", + input: "Info", + wantLevel: zerolog.InfoLevel, + }, + { + name: "uppercase WARN", + input: "WARN", + wantLevel: zerolog.WarnLevel, + }, + { + name: "uppercase ERROR", + input: "ERROR", + wantLevel: zerolog.ErrorLevel, + }, + + // Whitespace handling + { + name: "leading whitespace", + input: " debug", + wantLevel: zerolog.DebugLevel, + }, + { + name: "trailing whitespace", + input: "warn ", + wantLevel: zerolog.WarnLevel, + }, + { + name: "both whitespace", + input: " error ", + wantLevel: zerolog.ErrorLevel, + }, + { + name: "tabs", + input: "\tinfo\t", + wantLevel: zerolog.InfoLevel, + }, + + // Empty string defaults to info + { + name: "empty string defaults to info", + input: "", + wantLevel: zerolog.InfoLevel, + }, + { + name: "whitespace only defaults to info", + input: " ", + wantLevel: zerolog.InfoLevel, + }, + { + name: "tabs only defaults to info", + input: "\t\t", + wantLevel: zerolog.InfoLevel, + }, + + // Invalid levels + { + name: "invalid level returns error", + input: "invalid", + wantLevel: zerolog.InfoLevel, + wantErr: true, + errSubstr: "invalid log level", + }, + { + name: "typo returns error", + input: "debuf", + wantLevel: zerolog.InfoLevel, + wantErr: true, + errSubstr: "must be debug, info, warn, or error", + }, + { + name: "verbose returns error", + input: "verbose", + wantLevel: zerolog.InfoLevel, + wantErr: true, + errSubstr: "invalid log level", + }, + + // Trace level is outside allowed range (DebugLevel to ErrorLevel) + { + name: "trace level rejected (outside allowed range)", + input: "trace", + wantLevel: zerolog.InfoLevel, + wantErr: true, + errSubstr: "must be debug, info, warn, or error", + }, + + // Fatal/panic levels are outside allowed range + { + name: "fatal level rejected (outside allowed range)", + input: "fatal", + wantLevel: zerolog.InfoLevel, + wantErr: true, + errSubstr: "must be debug, info, warn, or error", + }, + { + name: "panic level rejected (outside allowed range)", + input: "panic", + wantLevel: zerolog.InfoLevel, + wantErr: true, + errSubstr: "must be debug, info, warn, or error", + }, + + // Numeric values (zerolog supports these) + { + name: "numeric 0 maps to debug level", + input: "0", + wantLevel: zerolog.DebugLevel, + }, + { + name: "numeric 1 maps to info level", + input: "1", + wantLevel: zerolog.InfoLevel, + }, + { + name: "numeric 2 maps to warn level", + input: "2", + wantLevel: zerolog.WarnLevel, + }, + { + name: "numeric 3 maps to error level", + input: "3", + wantLevel: zerolog.ErrorLevel, + }, + { + name: "numeric -1 is trace (rejected - outside range)", + input: "-1", + wantLevel: zerolog.InfoLevel, + wantErr: true, + errSubstr: "must be debug, info, warn, or error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + level, err := parseLogLevel(tt.input) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Fatalf("expected error containing %q, got %q", tt.errSubstr, err.Error()) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + if level != tt.wantLevel { + t.Fatalf("expected level %v, got %v", tt.wantLevel, level) + } + }) + } +} + +func TestDefaultLogLevel(t *testing.T) { + tests := []struct { + name string + envValue string + expected string + }{ + // Empty returns "info" + { + name: "empty string returns info", + envValue: "", + expected: "info", + }, + { + name: "whitespace only returns info", + envValue: " ", + expected: "info", + }, + { + name: "tabs only returns info", + envValue: "\t\t", + expected: "info", + }, + { + name: "newline only returns info", + envValue: "\n", + expected: "info", + }, + + // Non-empty returns as-is (no validation) + { + name: "debug returns debug", + envValue: "debug", + expected: "debug", + }, + { + name: "error returns error", + envValue: "error", + expected: "error", + }, + { + name: "invalid value passed through", + envValue: "invalid", + expected: "invalid", + }, + { + name: "mixed case passed through", + envValue: "DEBUG", + expected: "DEBUG", + }, + { + name: "value with surrounding whitespace NOT trimmed", + envValue: " debug ", + expected: " debug ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := defaultLogLevel(tt.envValue) + if got != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, got) + } + }) + } +} + +func TestMultiValue(t *testing.T) { + t.Run("String joins with comma", func(t *testing.T) { + mv := multiValue{"a", "b", "c"} + if got := mv.String(); got != "a,b,c" { + t.Fatalf("expected %q, got %q", "a,b,c", got) + } + }) + + t.Run("String empty slice returns empty string", func(t *testing.T) { + mv := multiValue{} + if got := mv.String(); got != "" { + t.Fatalf("expected %q, got %q", "", got) + } + }) + + t.Run("String single item no comma", func(t *testing.T) { + mv := multiValue{"single"} + if got := mv.String(); got != "single" { + t.Fatalf("expected %q, got %q", "single", got) + } + }) + + t.Run("Set appends values", func(t *testing.T) { + mv := multiValue{} + if err := mv.Set("first"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := mv.Set("second"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := mv.Set("third"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := multiValue{"first", "second", "third"} + if !reflect.DeepEqual(mv, expected) { + t.Fatalf("expected %v, got %v", expected, mv) + } + }) + + t.Run("Set preserves empty strings", func(t *testing.T) { + mv := multiValue{} + _ = mv.Set("") + _ = mv.Set("value") + _ = mv.Set("") + + if len(mv) != 3 { + t.Fatalf("expected 3 items, got %d", len(mv)) + } + }) + + t.Run("Set always returns nil error", func(t *testing.T) { + mv := multiValue{} + // Set always returns nil, testing various inputs + inputs := []string{"", "normal", "with spaces", "special!@#$%", "unicode日本語"} + for _, input := range inputs { + if err := mv.Set(input); err != nil { + t.Fatalf("expected nil error for input %q, got %v", input, err) + } + } + }) +}