Here we go again...

This commit is contained in:
2025-08-28 21:25:10 +04:00
parent 25d4762a3c
commit 42335c1178
62 changed files with 4579 additions and 4460 deletions

250
pkg/config/cli.go Normal file
View File

@@ -0,0 +1,250 @@
package config
import (
"flag"
"fmt"
"net/url"
"strings"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
)
const cliUsageText = `Usage:
dodo [flags]
Examples:
Simple usage:
dodo -u https://example.com -o 1m
Usage with config file:
dodo -f /path/to/config/file/config.json
Usage with all flags:
dodo -f /path/to/config/file/config.json \
-u https://example.com -m POST \
-d 10 -r 1000 -o 3m -t 3s \
-b "body1" -body "body2" \
-H "header1:value1" -header "header2:value2" \
-p "param1=value1" -param "param2=value2" \
-c "cookie1=value1" -cookie "cookie2=value2" \
-x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \
-skip-verify -y
Flags:
-h, -help help for dodo
-v, -version version for dodo
-y, -yes bool Answer yes to all questions (default %v)
-f, -config-file string Path to the local config file or http(s) URL of the config file
-d, -dodos uint Number of dodos(threads) (default %d)
-r, -requests uint Number of total requests
-o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h)
-t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v)
-u, -url string URL for stress testing
-m, -method string HTTP Method for the request (default %s)
-b, -body [string] Body for the request (e.g. "body text")
-p, -param [string] Parameter for the request (e.g. "key1=value1")
-H, -header [string] Header for the request (e.g. "key1: value1")
-c, -cookie [string] Cookie for the request (e.g. "key1=value1")
-x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080")
-skip-verify bool Skip SSL/TLS certificate verification (default %v)`
type ConfigCLIParser struct {
args []string
}
func NewConfigCLIParser(args []string) *ConfigCLIParser {
if args == nil {
args = []string{}
}
return &ConfigCLIParser{args}
}
type stringSliceArg []string
func (arg *stringSliceArg) String() string {
return strings.Join(*arg, ",")
}
func (arg *stringSliceArg) Set(value string) error {
*arg = append(*arg, value)
return nil
}
// Parse parses command-line arguments into a Config object.
// It can return the following errors:
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError
// - types.FieldParseErrors
func (parser *ConfigCLIParser) Parse() (*Config, error) {
flagSet := flag.NewFlagSet("dodo", flag.ExitOnError)
flagSet.Usage = func() { parser.PrintHelp() }
var (
config = &Config{}
yes bool
skipVerify bool
method string
urlInput string
dodosCount uint
requestCount uint
duration time.Duration
timeout time.Duration
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
bodies = stringSliceArg{}
proxies = stringSliceArg{}
)
{
flagSet.BoolVar(&yes, "yes", false, "Answer yes to all questions")
flagSet.BoolVar(&yes, "y", false, "Answer yes to all questions")
flagSet.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification")
flagSet.StringVar(&method, "method", "", "HTTP Method")
flagSet.StringVar(&method, "m", "", "HTTP Method")
flagSet.StringVar(&urlInput, "url", "", "URL to send the request")
flagSet.StringVar(&urlInput, "u", "", "URL to send the request")
flagSet.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
flagSet.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
flagSet.UintVar(&requestCount, "requests", 0, "Number of total requests")
flagSet.UintVar(&requestCount, "r", 0, "Number of total requests")
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
flagSet.DurationVar(&duration, "o", 0, "Maximum duration of the test")
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flagSet.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
flagSet.Var(&params, "param", "URL parameter to send with the request")
flagSet.Var(&params, "p", "URL parameter to send with the request")
flagSet.Var(&headers, "header", "Header to send with the request")
flagSet.Var(&headers, "H", "Header to send with the request")
flagSet.Var(&cookies, "cookie", "Cookie to send with the request")
flagSet.Var(&cookies, "c", "Cookie to send with the request")
flagSet.Var(&bodies, "body", "Body to send with the request")
flagSet.Var(&bodies, "b", "Body to send with the request")
flagSet.Var(&proxies, "proxy", "Proxy to use for the request")
flagSet.Var(&proxies, "x", "Proxy to use for the request")
}
// Parse the specific arguments provided to the parser, skipping the program name.
if err := flagSet.Parse(parser.args[1:]); err != nil {
panic(err)
}
// Check if no flags were set and no non-flag arguments were provided.
// This covers cases where `dodo` is run without any meaningful arguments.
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
return nil, types.ErrCLINoArgs
}
// Check for any unexpected non-flag arguments remaining after parsing.
if args := flagSet.Args(); len(args) > 0 {
return nil, types.NewCLIUnexpectedArgsError(args)
}
var fieldParseErrors []types.FieldParseError
// Iterate over flags that were explicitly set on the command line.
flagSet.Visit(func(flagVar *flag.Flag) {
switch flagVar.Name {
case "yes", "y":
config.Yes = utils.ToPtr(yes)
case "skip-verify":
config.SkipVerify = utils.ToPtr(skipVerify)
case "method", "m":
config.Method = utils.ToPtr(method)
case "url", "u":
urlParsed, err := url.Parse(urlInput)
if err != nil {
fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", err))
} else {
config.URL = urlParsed
}
case "dodos", "d":
config.DodosCount = utils.ToPtr(dodosCount)
case "requests", "r":
config.RequestCount = utils.ToPtr(requestCount)
case "duration", "o":
config.Duration = utils.ToPtr(duration)
case "timeout", "t":
config.Timeout = utils.ToPtr(timeout)
case "param", "p":
config.Params.Parse(params...)
case "header", "H":
config.Headers.Parse(headers...)
case "cookie", "c":
config.Cookies.Parse(cookies...)
case "body", "b":
config.Bodies.Parse(bodies...)
case "proxy", "x":
for i, proxy := range proxies {
err := config.Proxies.Parse(proxy)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
*types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), err),
)
}
}
}
})
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
return config, nil
}
func (parser *ConfigCLIParser) PrintHelp() {
fmt.Printf(
cliUsageText+"\n",
Defaults.Yes,
Defaults.DodosCount,
Defaults.RequestTimeout,
Defaults.Method,
Defaults.SkipVerify,
)
}
// CLIYesOrNoReader reads a yes or no answer from the command line.
// It prompts the user with the given message and default value,
// and returns true if the user answers "y" or "Y", and false otherwise.
// If there is an error while reading the input, it returns false.
// If the user simply presses enter without providing any input,
// it returns the default value specified by the `def` parameter.
func CLIYesOrNoReader(message string, def bool) bool {
var answer string
defaultMessage := "Y/n"
if !def {
defaultMessage = "y/N"
}
fmt.Printf("%s [%s]: ", message, defaultMessage)
if _, err := fmt.Scanln(&answer); err != nil {
if err.Error() == "unexpected newline" {
return def
}
return false
}
if answer == "" {
return def
}
return answer == "y" || answer == "Y"
}

679
pkg/config/cli_test.go Normal file
View File

@@ -0,0 +1,679 @@
package config
import (
"bytes"
"io"
"net/url"
"os"
"testing"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewConfigCLIParser(t *testing.T) {
t.Run("NewConfigCLIParser with valid args", func(t *testing.T) {
args := []string{"dodo", "-u", "https://example.com"}
parser := NewConfigCLIParser(args)
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
})
t.Run("NewConfigCLIParser with nil args", func(t *testing.T) {
parser := NewConfigCLIParser(nil)
require.NotNil(t, parser)
assert.Equal(t, []string{}, parser.args)
})
t.Run("NewConfigCLIParser with empty args", func(t *testing.T) {
args := []string{}
parser := NewConfigCLIParser(args)
require.NotNil(t, parser)
assert.Equal(t, args, parser.args)
})
}
func TestStringSliceArg(t *testing.T) {
t.Run("stringSliceArg String method", func(t *testing.T) {
arg := stringSliceArg{"value1", "value2", "value3"}
assert.Equal(t, "value1,value2,value3", arg.String())
})
t.Run("stringSliceArg String with empty slice", func(t *testing.T) {
arg := stringSliceArg{}
assert.Empty(t, arg.String())
})
t.Run("stringSliceArg String with single value", func(t *testing.T) {
arg := stringSliceArg{"single"}
assert.Equal(t, "single", arg.String())
})
t.Run("stringSliceArg Set method", func(t *testing.T) {
arg := &stringSliceArg{}
err := arg.Set("first")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{"first"}, *arg)
err = arg.Set("second")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{"first", "second"}, *arg)
})
t.Run("stringSliceArg Set with empty string", func(t *testing.T) {
arg := &stringSliceArg{}
err := arg.Set("")
require.NoError(t, err)
assert.Equal(t, stringSliceArg{""}, *arg)
})
}
func TestConfigCLIParser_Parse(t *testing.T) {
t.Run("Parse with no arguments returns ErrCLINoArgs", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo"})
config, err := parser.Parse()
assert.Nil(t, config)
require.ErrorIs(t, err, types.ErrCLINoArgs)
})
t.Run("Parse with unexpected arguments returns CLIUnexpectedArgsError", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "unexpected", "args"})
config, err := parser.Parse()
assert.Nil(t, config)
var cliErr types.CLIUnexpectedArgsError
require.ErrorAs(t, err, &cliErr)
assert.Equal(t, []string{"unexpected", "args"}, cliErr.Args)
})
t.Run("Parse with valid URL", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-u", "https://example.com"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.URL)
assert.Equal(t, "https://example.com", config.URL.String())
})
t.Run("Parse with invalid URL returns FieldParseErrors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-u", "://invalid-url"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "url", fieldErr.Errors[0].Field)
})
t.Run("Parse with method flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-m", "POST"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Method)
assert.Equal(t, "POST", *config.Method)
})
t.Run("Parse with yes flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-y"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Yes)
assert.True(t, *config.Yes)
})
t.Run("Parse with skip-verify flag", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-skip-verify"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.SkipVerify)
assert.True(t, *config.SkipVerify)
})
t.Run("Parse with dodos count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-d", "5"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.DodosCount)
assert.Equal(t, uint(5), *config.DodosCount)
})
t.Run("Parse with request count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-r", "1000"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.RequestCount)
assert.Equal(t, uint(1000), *config.RequestCount)
})
t.Run("Parse with duration", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-o", "5m"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Duration)
assert.Equal(t, 5*time.Minute, *config.Duration)
})
t.Run("Parse with timeout", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-t", "30s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Timeout)
assert.Equal(t, 30*time.Second, *config.Timeout)
})
t.Run("Parse with parameters", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-p", "key1=value1", "-p", "key2=value2"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Params, 2)
assert.Equal(t, "key1", config.Params[0].Key)
assert.Equal(t, []string{"value1"}, config.Params[0].Value)
assert.Equal(t, "key2", config.Params[1].Key)
assert.Equal(t, []string{"value2"}, config.Params[1].Value)
})
t.Run("Parse with headers", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer token"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Headers, 2)
assert.Equal(t, "Content-Type", config.Headers[0].Key)
assert.Equal(t, []string{"application/json"}, config.Headers[0].Value)
assert.Equal(t, "Authorization", config.Headers[1].Key)
assert.Equal(t, []string{"Bearer token"}, config.Headers[1].Value)
})
t.Run("Parse with cookies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-c", "session=abc123", "-c", "user=john"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Cookies, 2)
assert.Equal(t, "session", config.Cookies[0].Key)
assert.Equal(t, []string{"abc123"}, config.Cookies[0].Value)
assert.Equal(t, "user", config.Cookies[1].Key)
assert.Equal(t, []string{"john"}, config.Cookies[1].Value)
})
t.Run("Parse with bodies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-b", "body1", "-b", "body2"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Bodies, 2)
assert.Equal(t, types.Body("body1"), config.Bodies[0])
assert.Equal(t, types.Body("body2"), config.Bodies[1])
})
t.Run("Parse with valid proxies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://proxy1.example.com:8080", "-x", "socks5://proxy2.example.com:1080"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Len(t, config.Proxies, 2)
assert.Equal(t, "http://proxy1.example.com:8080", config.Proxies[0].String())
assert.Equal(t, "socks5://proxy2.example.com:1080", config.Proxies[1].String())
})
t.Run("Parse with invalid proxy returns FieldParseErrors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "://invalid-proxy"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "proxy[0]", fieldErr.Errors[0].Field)
})
t.Run("Parse with mixed valid and invalid proxies", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-x", "http://valid.example.com:8080", "-x", "://invalid"})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 1)
assert.Equal(t, "proxy[1]", fieldErr.Errors[0].Field)
})
t.Run("Parse with long flag names", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"--url", "https://example.com",
"--method", "POST",
"--yes",
"--skip-verify",
"--dodos", "3",
"--requests", "500",
"--duration", "1m",
"--timeout", "10s",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, "https://example.com", config.URL.String())
assert.Equal(t, "POST", *config.Method)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, uint(3), *config.DodosCount)
assert.Equal(t, uint(500), *config.RequestCount)
assert.Equal(t, time.Minute, *config.Duration)
assert.Equal(t, 10*time.Second, *config.Timeout)
})
t.Run("Parse with all flags combined", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-u", "https://api.example.com/test",
"-m", "PUT",
"-y",
"-skip-verify",
"-d", "10",
"-r", "2000",
"-o", "30m",
"-t", "5s",
"-p", "apikey=123",
"-H", "Content-Type: application/json",
"-c", "session=token123",
"-b", `{"data": "test"}`,
"-x", "http://proxy.example.com:3128",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
// Verify all fields are set correctly
assert.Equal(t, "https://api.example.com/test", config.URL.String())
assert.Equal(t, "PUT", *config.Method)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, uint(10), *config.DodosCount)
assert.Equal(t, uint(2000), *config.RequestCount)
assert.Equal(t, 30*time.Minute, *config.Duration)
assert.Equal(t, 5*time.Second, *config.Timeout)
assert.Len(t, config.Params, 1)
assert.Equal(t, "apikey", config.Params[0].Key)
assert.Len(t, config.Headers, 1)
assert.Equal(t, "Content-Type", config.Headers[0].Key)
assert.Len(t, config.Cookies, 1)
assert.Equal(t, "session", config.Cookies[0].Key)
assert.Len(t, config.Bodies, 1)
assert.Equal(t, types.Body(`{"data": "test"}`), config.Bodies[0]) //nolint:testifylint
assert.Len(t, config.Proxies, 1)
assert.Equal(t, "http://proxy.example.com:3128", config.Proxies[0].String())
})
t.Run("Parse with multiple field parse errors", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-u", "://invalid-url",
"-x", "://invalid-proxy1",
"-x", "://invalid-proxy2",
})
config, err := parser.Parse()
assert.Nil(t, config)
var fieldErr types.FieldParseErrors
require.ErrorAs(t, err, &fieldErr)
assert.Len(t, fieldErr.Errors, 3)
// Check error fields
fields := make(map[string]bool)
for _, parseErr := range fieldErr.Errors {
fields[parseErr.Field] = true
}
assert.True(t, fields["url"])
assert.True(t, fields["proxy[0]"])
assert.True(t, fields["proxy[1]"])
})
}
func TestConfigCLIParser_PrintHelp(t *testing.T) {
t.Run("PrintHelp outputs expected content", func(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
reader, writer, _ := os.Pipe()
os.Stdout = writer
parser := NewConfigCLIParser([]string{"dodo"})
parser.PrintHelp()
// Restore stdout and read output
writer.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
// Verify help text contains expected elements
assert.Contains(t, output, "Usage:")
assert.Contains(t, output, "dodo [flags]")
assert.Contains(t, output, "Examples:")
assert.Contains(t, output, "Flags:")
assert.Contains(t, output, "-h, -help")
assert.Contains(t, output, "-v, -version")
assert.Contains(t, output, "-u, -url")
assert.Contains(t, output, "-m, -method")
assert.Contains(t, output, "-d, -dodos")
assert.Contains(t, output, "-r, -requests")
assert.Contains(t, output, "-t, -timeout")
assert.Contains(t, output, "-b, -body")
assert.Contains(t, output, "-H, -header")
assert.Contains(t, output, "-p, -param")
assert.Contains(t, output, "-c, -cookie")
assert.Contains(t, output, "-x, -proxy")
assert.Contains(t, output, "-skip-verify")
assert.Contains(t, output, "-y, -yes")
// Verify default values are included
assert.Contains(t, output, Defaults.Method)
assert.Contains(t, output, "1") // DodosCount default
assert.Contains(t, output, "10s") // RequestTimeout default
assert.Contains(t, output, "false") // Yes default
assert.Contains(t, output, "false") // SkipVerify default
})
}
func TestCLIYesOrNoReader(t *testing.T) {
t.Run("CLIYesOrNoReader with 'y' input returns true", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("y\n")
writer.Close()
result := CLIYesOrNoReader("Test question", false)
// Restore stdin
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with 'Y' input returns true", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("Y\n")
writer.Close()
result := CLIYesOrNoReader("Test question", false)
// Restore stdin
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with 'n' input returns false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write input and close writer
writer.WriteString("n\n")
writer.Close()
result := CLIYesOrNoReader("Test question", true)
// Restore stdin
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader with empty input returns default", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write just newline and close writer
writer.WriteString("\n")
writer.Close()
// Test with default true
result := CLIYesOrNoReader("Test question", true)
os.Stdin = oldStdin
assert.True(t, result)
})
t.Run("CLIYesOrNoReader with empty input returns default false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write just newline and close writer
writer.WriteString("\n")
writer.Close()
// Test with default false
result := CLIYesOrNoReader("Test question", false)
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader with other input returns false", func(t *testing.T) {
// Redirect stdin
oldStdin := os.Stdin
reader, writer, _ := os.Pipe()
os.Stdin = reader
// Write other input and close writer
writer.WriteString("maybe\n")
writer.Close()
result := CLIYesOrNoReader("Test question", true)
// Restore stdin
os.Stdin = oldStdin
assert.False(t, result)
})
t.Run("CLIYesOrNoReader message format with default true", func(t *testing.T) {
// Capture stdout to verify message format
oldStdout := os.Stdout
stdoutReader, stdoutWriter, _ := os.Pipe()
os.Stdout = stdoutWriter
// Redirect stdin
oldStdin := os.Stdin
stdinReader, stdinWriter, _ := os.Pipe()
os.Stdin = stdinReader
// Write input and close writer
stdinWriter.WriteString("y\n")
stdinWriter.Close()
CLIYesOrNoReader("Continue?", true)
// Restore stdin and stdout
os.Stdin = oldStdin
stdoutWriter.Close()
os.Stdout = oldStdout
// Read output
var buf bytes.Buffer
io.Copy(&buf, stdoutReader)
output := buf.String()
assert.Contains(t, output, "Continue? [Y/n]:")
})
t.Run("CLIYesOrNoReader message format with default false", func(t *testing.T) {
// Capture stdout to verify message format
oldStdout := os.Stdout
stdoutReader, stdoutWriter, _ := os.Pipe()
os.Stdout = stdoutWriter
// Redirect stdin
oldStdin := os.Stdin
stdinReader, stdinWriter, _ := os.Pipe()
os.Stdin = stdinReader
// Write input and close writer
stdinWriter.WriteString("n\n")
stdinWriter.Close()
CLIYesOrNoReader("Delete files?", false)
// Restore stdin and stdout
os.Stdin = oldStdin
stdoutWriter.Close()
os.Stdout = oldStdout
// Read output
var buf bytes.Buffer
io.Copy(&buf, stdoutReader)
output := buf.String()
assert.Contains(t, output, "Delete files? [y/N]:")
})
}
func TestConfigCLIParser_EdgeCases(t *testing.T) {
t.Run("Parse with zero duration", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-o", "0s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Duration)
assert.Equal(t, time.Duration(0), *config.Duration)
})
t.Run("Parse with zero timeout", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-t", "0s"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Timeout)
assert.Equal(t, time.Duration(0), *config.Timeout)
})
t.Run("Parse with zero dodos count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-d", "0"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.DodosCount)
assert.Equal(t, uint(0), *config.DodosCount)
})
t.Run("Parse with zero request count", func(t *testing.T) {
parser := NewConfigCLIParser([]string{"dodo", "-r", "0"})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.RequestCount)
assert.Equal(t, uint(0), *config.RequestCount)
})
t.Run("Parse with empty string values", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-m", "",
"-p", "",
"-H", "",
"-c", "",
"-b", "",
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Empty(t, *config.Method)
assert.Len(t, config.Params, 1)
assert.Empty(t, config.Params[0].Key)
assert.Len(t, config.Headers, 1)
assert.Empty(t, config.Headers[0].Key)
assert.Len(t, config.Cookies, 1)
assert.Empty(t, config.Cookies[0].Key)
assert.Len(t, config.Bodies, 1)
assert.Equal(t, types.Body(""), config.Bodies[0])
})
t.Run("Parse with complex URL", func(t *testing.T) {
complexURL := "https://user:pass@api.example.com:8080/v1/endpoint?param=value&other=test#fragment"
parser := NewConfigCLIParser([]string{"dodo", "-u", complexURL})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.URL)
parsedURL, parseErr := url.Parse(complexURL)
require.NoError(t, parseErr)
assert.Equal(t, parsedURL, config.URL)
})
t.Run("Parse with repeated same flags overrides previous values", func(t *testing.T) {
parser := NewConfigCLIParser([]string{
"dodo",
"-m", "GET",
"-m", "POST", // This should override the previous
"-d", "1",
"-d", "5", // This should override the previous
})
config, err := parser.Parse()
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, "POST", *config.Method)
assert.Equal(t, uint(5), *config.DodosCount)
})
}

112
pkg/config/config.go Normal file
View File

@@ -0,0 +1,112 @@
package config
import (
"net/url"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
)
const VERSION string = "1.0.0"
var Defaults = struct {
UserAgent string
Method string
RequestTimeout time.Duration
DodosCount uint
Yes bool
SkipVerify bool
}{
UserAgent: "dodo/" + VERSION,
Method: "GET",
RequestTimeout: time.Second * 10,
DodosCount: 1,
Yes: false,
SkipVerify: false,
}
var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
type Config struct {
Method *string
URL *url.URL
Timeout *time.Duration
DodosCount *uint
RequestCount *uint
Duration *time.Duration
Yes *bool
SkipVerify *bool
Params types.Params
Headers types.Headers
Cookies types.Cookies
Bodies types.Bodies
Proxies types.Proxies
}
func NewConfig() *Config {
return &Config{}
}
func (config *Config) MergeConfig(newConfig *Config) {
if newConfig.Method != nil {
config.Method = newConfig.Method
}
if newConfig.URL != nil {
config.URL = newConfig.URL
}
if newConfig.Timeout != nil {
config.Timeout = newConfig.Timeout
}
if newConfig.DodosCount != nil {
config.DodosCount = newConfig.DodosCount
}
if newConfig.RequestCount != nil {
config.RequestCount = newConfig.RequestCount
}
if newConfig.Duration != nil {
config.Duration = newConfig.Duration
}
if newConfig.Yes != nil {
config.Yes = newConfig.Yes
}
if newConfig.SkipVerify != nil {
config.SkipVerify = newConfig.SkipVerify
}
if len(newConfig.Params) != 0 {
config.Params = newConfig.Params
}
if len(newConfig.Headers) != 0 {
config.Headers = newConfig.Headers
}
if len(newConfig.Cookies) != 0 {
config.Cookies = newConfig.Cookies
}
if len(newConfig.Bodies) != 0 {
config.Bodies = newConfig.Bodies
}
if len(newConfig.Proxies) != 0 {
config.Proxies = newConfig.Proxies
}
}
func (config *Config) SetDefaults() {
if config.Method == nil {
config.Method = utils.ToPtr(Defaults.Method)
}
if config.Timeout == nil {
config.Timeout = &Defaults.RequestTimeout
}
if config.DodosCount == nil {
config.DodosCount = utils.ToPtr(Defaults.DodosCount)
}
if config.Yes == nil {
config.Yes = utils.ToPtr(Defaults.Yes)
}
if config.SkipVerify == nil {
config.SkipVerify = utils.ToPtr(Defaults.SkipVerify)
}
if !config.Headers.Has("User-Agent") {
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
}
}

318
pkg/config/config_test.go Normal file
View File

@@ -0,0 +1,318 @@
package config
import (
"net/url"
"testing"
"time"
"github.com/aykhans/dodo/pkg/types"
"github.com/aykhans/dodo/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeConfig(t *testing.T) {
t.Run("MergeConfig with all fields from new config", func(t *testing.T) {
originalURL, _ := url.Parse("https://original.example.com")
newURL, _ := url.Parse("https://new.example.com")
originalTimeout := 5 * time.Second
newTimeout := 10 * time.Second
originalDuration := 1 * time.Minute
newDuration := 2 * time.Minute
config := &Config{
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
DodosCount: utils.ToPtr(uint(1)),
RequestCount: utils.ToPtr(uint(10)),
Duration: &originalDuration,
Yes: utils.ToPtr(false),
SkipVerify: utils.ToPtr(false),
Params: types.Params{{Key: "old", Value: []string{"value"}}},
Headers: types.Headers{{Key: "Old-Header", Value: []string{"old"}}},
Cookies: types.Cookies{{Key: "oldCookie", Value: []string{"oldValue"}}},
Bodies: types.Bodies{types.Body("old body")},
Proxies: types.Proxies{},
}
newConfig := &Config{
Method: utils.ToPtr("POST"),
URL: newURL,
Timeout: &newTimeout,
DodosCount: utils.ToPtr(uint(5)),
RequestCount: utils.ToPtr(uint(20)),
Duration: &newDuration,
Yes: utils.ToPtr(true),
SkipVerify: utils.ToPtr(true),
Params: types.Params{{Key: "new", Value: []string{"value"}}},
Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
Bodies: types.Bodies{types.Body("new body")},
Proxies: types.Proxies{},
}
config.MergeConfig(newConfig)
assert.Equal(t, "POST", *config.Method)
assert.Equal(t, newURL, config.URL)
assert.Equal(t, newTimeout, *config.Timeout)
assert.Equal(t, uint(5), *config.DodosCount)
assert.Equal(t, uint(20), *config.RequestCount)
assert.Equal(t, newDuration, *config.Duration)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
assert.Empty(t, config.Proxies)
})
t.Run("MergeConfig with partial fields from new config", func(t *testing.T) {
originalURL, _ := url.Parse("https://original.example.com")
originalTimeout := 5 * time.Second
config := &Config{
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
DodosCount: utils.ToPtr(uint(1)),
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
}
newURL, _ := url.Parse("https://new.example.com")
newConfig := &Config{
URL: newURL,
DodosCount: utils.ToPtr(uint(10)),
}
config.MergeConfig(newConfig)
assert.Equal(t, "GET", *config.Method, "Method should remain unchanged")
assert.Equal(t, newURL, config.URL, "URL should be updated")
assert.Equal(t, originalTimeout, *config.Timeout, "Timeout should remain unchanged")
assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should be updated")
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged")
})
t.Run("MergeConfig with nil new config fields", func(t *testing.T) {
originalURL, _ := url.Parse("https://original.example.com")
originalTimeout := 5 * time.Second
config := &Config{
Method: utils.ToPtr("GET"),
URL: originalURL,
Timeout: &originalTimeout,
DodosCount: utils.ToPtr(uint(1)),
Yes: utils.ToPtr(false),
SkipVerify: utils.ToPtr(false),
}
newConfig := &Config{
Method: nil,
URL: nil,
Timeout: nil,
DodosCount: nil,
Yes: nil,
SkipVerify: nil,
}
originalConfigCopy := *config
config.MergeConfig(newConfig)
assert.Equal(t, originalConfigCopy.Method, config.Method)
assert.Equal(t, originalConfigCopy.URL, config.URL)
assert.Equal(t, originalConfigCopy.Timeout, config.Timeout)
assert.Equal(t, originalConfigCopy.DodosCount, config.DodosCount)
assert.Equal(t, originalConfigCopy.Yes, config.Yes)
assert.Equal(t, originalConfigCopy.SkipVerify, config.SkipVerify)
})
t.Run("MergeConfig with empty slices", func(t *testing.T) {
config := &Config{
Params: types.Params{{Key: "original", Value: []string{"value"}}},
Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
Cookies: types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}},
Bodies: types.Bodies{types.Body("original body")},
Proxies: types.Proxies{},
}
newConfig := &Config{
Params: types.Params{},
Headers: types.Headers{},
Cookies: types.Cookies{},
Bodies: types.Bodies{},
Proxies: types.Proxies{},
}
config.MergeConfig(newConfig)
assert.Equal(t, types.Params{{Key: "original", Value: []string{"value"}}}, config.Params, "Empty Params should not override")
assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Empty Headers should not override")
assert.Equal(t, types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, config.Cookies, "Empty Cookies should not override")
assert.Equal(t, types.Bodies{types.Body("original body")}, config.Bodies, "Empty Bodies should not override")
assert.Equal(t, types.Proxies{}, config.Proxies, "Empty Proxies should not override")
})
t.Run("MergeConfig on empty original config", func(t *testing.T) {
config := &Config{}
newURL, _ := url.Parse("https://new.example.com")
newTimeout := 10 * time.Second
newDuration := 2 * time.Minute
newConfig := &Config{
Method: utils.ToPtr("POST"),
URL: newURL,
Timeout: &newTimeout,
DodosCount: utils.ToPtr(uint(5)),
RequestCount: utils.ToPtr(uint(20)),
Duration: &newDuration,
Yes: utils.ToPtr(true),
SkipVerify: utils.ToPtr(true),
Params: types.Params{{Key: "new", Value: []string{"value"}}},
Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
Bodies: types.Bodies{types.Body("new body")},
Proxies: types.Proxies{},
}
config.MergeConfig(newConfig)
assert.Equal(t, "POST", *config.Method)
assert.Equal(t, newURL, config.URL)
assert.Equal(t, newTimeout, *config.Timeout)
assert.Equal(t, uint(5), *config.DodosCount)
assert.Equal(t, uint(20), *config.RequestCount)
assert.Equal(t, newDuration, *config.Duration)
assert.True(t, *config.Yes)
assert.True(t, *config.SkipVerify)
assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
assert.Empty(t, config.Proxies)
})
}
func TestSetDefaults(t *testing.T) {
t.Run("SetDefaults on empty config", func(t *testing.T) {
config := &Config{}
config.SetDefaults()
require.NotNil(t, config.Method)
assert.Equal(t, Defaults.Method, *config.Method)
require.NotNil(t, config.Timeout)
assert.Equal(t, Defaults.RequestTimeout, *config.Timeout)
require.NotNil(t, config.DodosCount)
assert.Equal(t, Defaults.DodosCount, *config.DodosCount)
require.NotNil(t, config.Yes)
assert.Equal(t, Defaults.Yes, *config.Yes)
require.NotNil(t, config.SkipVerify)
assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify)
assert.True(t, config.Headers.Has("User-Agent"))
assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
})
t.Run("SetDefaults preserves existing values", func(t *testing.T) {
customTimeout := 30 * time.Second
config := &Config{
Method: utils.ToPtr("POST"),
Timeout: &customTimeout,
DodosCount: utils.ToPtr(uint(10)),
Yes: utils.ToPtr(true),
SkipVerify: utils.ToPtr(true),
Headers: types.Headers{{Key: "User-Agent", Value: []string{"custom-agent"}}},
}
config.SetDefaults()
assert.Equal(t, "POST", *config.Method, "Method should not be overridden")
assert.Equal(t, customTimeout, *config.Timeout, "Timeout should not be overridden")
assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should not be overridden")
assert.True(t, *config.Yes, "Yes should not be overridden")
assert.True(t, *config.SkipVerify, "SkipVerify should not be overridden")
assert.Equal(t, "custom-agent", config.Headers[0].Value[0], "User-Agent should not be overridden")
assert.Len(t, config.Headers, 1, "Should not add duplicate User-Agent")
})
t.Run("SetDefaults adds User-Agent when missing", func(t *testing.T) {
config := &Config{
Headers: types.Headers{{Key: "Content-Type", Value: []string{"application/json"}}},
}
config.SetDefaults()
assert.Len(t, config.Headers, 2)
assert.True(t, config.Headers.Has("User-Agent"))
assert.True(t, config.Headers.Has("Content-Type"))
var userAgentFound bool
for _, h := range config.Headers {
if h.Key == "User-Agent" {
userAgentFound = true
assert.Equal(t, Defaults.UserAgent, h.Value[0])
break
}
}
assert.True(t, userAgentFound, "User-Agent header should be added")
})
t.Run("SetDefaults with partial config", func(t *testing.T) {
config := &Config{
Method: utils.ToPtr("PUT"),
Yes: utils.ToPtr(true),
}
config.SetDefaults()
assert.Equal(t, "PUT", *config.Method, "Existing Method should be preserved")
assert.True(t, *config.Yes, "Existing Yes should be preserved")
require.NotNil(t, config.Timeout)
assert.Equal(t, Defaults.RequestTimeout, *config.Timeout, "Timeout should be set to default")
require.NotNil(t, config.DodosCount)
assert.Equal(t, Defaults.DodosCount, *config.DodosCount, "DodosCount should be set to default")
require.NotNil(t, config.SkipVerify)
assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify, "SkipVerify should be set to default")
assert.True(t, config.Headers.Has("User-Agent"))
})
t.Run("SetDefaults idempotent", func(t *testing.T) {
config := &Config{}
config.SetDefaults()
firstCallHeaders := len(config.Headers)
firstCallMethod := *config.Method
firstCallTimeout := *config.Timeout
config.SetDefaults()
assert.Len(t, config.Headers, firstCallHeaders, "Headers count should not change on second call")
assert.Equal(t, firstCallMethod, *config.Method, "Method should not change on second call")
assert.Equal(t, firstCallTimeout, *config.Timeout, "Timeout should not change on second call")
})
t.Run("SetDefaults with empty Headers initializes correctly", func(t *testing.T) {
config := &Config{
Headers: types.Headers{},
}
config.SetDefaults()
assert.Len(t, config.Headers, 1)
assert.Equal(t, "User-Agent", config.Headers[0].Key)
assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
})
}

23
pkg/types/body.go Normal file
View File

@@ -0,0 +1,23 @@
package types
type Body string
func (body Body) String() string {
return string(body)
}
type Bodies []Body
func (bodies *Bodies) Append(body Body) {
*bodies = append(*bodies, body)
}
func (bodies *Bodies) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
bodies.Append(ParseBody(rawValue))
}
}
func ParseBody(rawValue string) Body {
return Body(rawValue)
}

160
pkg/types/body_test.go Normal file
View File

@@ -0,0 +1,160 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBody_String(t *testing.T) {
t.Run("Body String returns correct value", func(t *testing.T) {
body := Body("test body content")
assert.Equal(t, "test body content", body.String())
})
t.Run("Body String with empty body", func(t *testing.T) {
body := Body("")
assert.Empty(t, body.String())
})
t.Run("Body String with JSON", func(t *testing.T) {
body := Body(`{"key": "value", "number": 42}`)
assert.JSONEq(t, `{"key": "value", "number": 42}`, body.String())
})
t.Run("Body String with special characters", func(t *testing.T) {
body := Body("special: !@#$%^&*()\nnewline\ttab")
assert.Equal(t, "special: !@#$%^&*()\nnewline\ttab", body.String())
})
}
func TestBodies_Append(t *testing.T) {
t.Run("Append single body", func(t *testing.T) {
bodies := &Bodies{}
bodies.Append(Body("first body"))
assert.Len(t, *bodies, 1)
assert.Equal(t, Body("first body"), (*bodies)[0])
})
t.Run("Append multiple bodies", func(t *testing.T) {
bodies := &Bodies{}
bodies.Append(Body("first"))
bodies.Append(Body("second"))
bodies.Append(Body("third"))
assert.Len(t, *bodies, 3)
assert.Equal(t, Body("first"), (*bodies)[0])
assert.Equal(t, Body("second"), (*bodies)[1])
assert.Equal(t, Body("third"), (*bodies)[2])
})
t.Run("Append to existing bodies", func(t *testing.T) {
bodies := &Bodies{Body("existing")}
bodies.Append(Body("new"))
assert.Len(t, *bodies, 2)
assert.Equal(t, Body("existing"), (*bodies)[0])
assert.Equal(t, Body("new"), (*bodies)[1])
})
t.Run("Append empty body", func(t *testing.T) {
bodies := &Bodies{}
bodies.Append(Body(""))
assert.Len(t, *bodies, 1)
assert.Empty(t, (*bodies)[0].String())
})
}
func TestBodies_Parse(t *testing.T) {
t.Run("Parse single value", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse("test body")
assert.Len(t, *bodies, 1)
assert.Equal(t, Body("test body"), (*bodies)[0])
})
t.Run("Parse multiple values", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse("body1", "body2", "body3")
assert.Len(t, *bodies, 3)
assert.Equal(t, Body("body1"), (*bodies)[0])
assert.Equal(t, Body("body2"), (*bodies)[1])
assert.Equal(t, Body("body3"), (*bodies)[2])
})
t.Run("Parse with existing bodies", func(t *testing.T) {
bodies := &Bodies{Body("existing")}
bodies.Parse("new1", "new2")
assert.Len(t, *bodies, 3)
assert.Equal(t, Body("existing"), (*bodies)[0])
assert.Equal(t, Body("new1"), (*bodies)[1])
assert.Equal(t, Body("new2"), (*bodies)[2])
})
t.Run("Parse empty values", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse("", "", "")
assert.Len(t, *bodies, 3)
for _, body := range *bodies {
assert.Empty(t, body.String())
}
})
t.Run("Parse no arguments", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse()
assert.Empty(t, *bodies)
})
t.Run("Parse JSON strings", func(t *testing.T) {
bodies := &Bodies{}
bodies.Parse(`{"key": "value"}`, `{"array": [1, 2, 3]}`)
assert.Len(t, *bodies, 2)
assert.JSONEq(t, `{"key": "value"}`, (*bodies)[0].String())
assert.JSONEq(t, `{"array": [1, 2, 3]}`, (*bodies)[1].String())
})
}
func TestParseBody(t *testing.T) {
t.Run("ParseBody with regular string", func(t *testing.T) {
body := ParseBody("test content")
assert.Equal(t, Body("test content"), body)
})
t.Run("ParseBody with empty string", func(t *testing.T) {
body := ParseBody("")
assert.Equal(t, Body(""), body)
})
t.Run("ParseBody with multiline string", func(t *testing.T) {
input := "line1\nline2\nline3"
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
t.Run("ParseBody with special characters", func(t *testing.T) {
input := "!@#$%^&*()_+-=[]{}|;':\",./<>?"
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
t.Run("ParseBody with unicode", func(t *testing.T) {
input := "Hello World 🌍"
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
t.Run("ParseBody preserves whitespace", func(t *testing.T) {
input := " leading and trailing spaces "
body := ParseBody(input)
assert.Equal(t, Body(input), body)
})
}

92
pkg/types/config_file.go Normal file
View File

@@ -0,0 +1,92 @@
package types
import (
"net/url"
"path/filepath"
"strings"
)
type ConfigFileType int
const (
ConfigFileTypeYAML ConfigFileType = iota
)
func (t ConfigFileType) String() string {
switch t {
case ConfigFileTypeYAML:
return "yaml"
default:
return "unknown"
}
}
type ConfigFileLocationType int
const (
ConfigFileLocationLocal ConfigFileLocationType = iota
ConfigFileLocationRemote
)
func (l ConfigFileLocationType) String() string {
switch l {
case ConfigFileLocationLocal:
return "local"
case ConfigFileLocationRemote:
return "remote"
default:
return "unknown"
}
}
type ConfigFile struct {
path string
_type ConfigFileType
locationType ConfigFileLocationType
}
func (configFile ConfigFile) String() string {
return configFile.path
}
func (configFile ConfigFile) Type() ConfigFileType {
return configFile._type
}
func (configFile ConfigFile) LocationType() ConfigFileLocationType {
return configFile.locationType
}
func ParseConfigFile(configFileRaw string) (*ConfigFile, error) {
configFileParsed := &ConfigFile{
path: configFileRaw,
locationType: ConfigFileLocationLocal,
}
if strings.HasPrefix(configFileRaw, "http://") || strings.HasPrefix(configFileRaw, "https://") {
configFileParsed.locationType = ConfigFileLocationRemote
}
configFilePath := configFileRaw
if configFileParsed.locationType == ConfigFileLocationRemote {
remoteConfigFileParsed, err := url.Parse(configFileRaw)
if err != nil {
return nil, NewRemoteConfigFileParseError(err)
}
configFilePath = remoteConfigFileParsed.Path
}
configFileExtension, _ := strings.CutPrefix(filepath.Ext(configFilePath), ".")
if configFileExtension == "" {
return nil, ErrConfigFileExtensionNotFound
}
switch strings.ToLower(configFileExtension) {
case "yml", "yaml":
configFileParsed._type = ConfigFileTypeYAML
default:
return nil, NewUnknownConfigFileTypeError(configFileExtension)
}
return configFileParsed, nil
}

View File

@@ -0,0 +1,216 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfigFileType_String(t *testing.T) {
t.Run("ConfigFileTypeYAML returns yaml", func(t *testing.T) {
configType := ConfigFileTypeYAML
assert.Equal(t, "yaml", configType.String())
})
t.Run("Unknown config file type returns unknown", func(t *testing.T) {
configType := ConfigFileType(999)
assert.Equal(t, "unknown", configType.String())
})
}
func TestConfigFileLocationType_String(t *testing.T) {
t.Run("ConfigFileLocationLocal returns local", func(t *testing.T) {
locationType := ConfigFileLocationLocal
assert.Equal(t, "local", locationType.String())
})
t.Run("ConfigFileLocationRemote returns remote", func(t *testing.T) {
locationType := ConfigFileLocationRemote
assert.Equal(t, "remote", locationType.String())
})
t.Run("Unknown location type returns unknown", func(t *testing.T) {
locationType := ConfigFileLocationType(999)
assert.Equal(t, "unknown", locationType.String())
})
}
func TestConfigFile_String(t *testing.T) {
t.Run("String returns the file path", func(t *testing.T) {
configFile := ConfigFile{path: "/path/to/config.yaml"}
assert.Equal(t, "/path/to/config.yaml", configFile.String())
})
t.Run("String returns empty path", func(t *testing.T) {
configFile := ConfigFile{path: ""}
assert.Empty(t, configFile.String())
})
}
func TestConfigFile_Type(t *testing.T) {
t.Run("Type returns the config file type", func(t *testing.T) {
configFile := ConfigFile{_type: ConfigFileTypeYAML}
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
})
}
func TestConfigFile_LocationType(t *testing.T) {
t.Run("LocationType returns local", func(t *testing.T) {
configFile := ConfigFile{locationType: ConfigFileLocationLocal}
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("LocationType returns remote", func(t *testing.T) {
configFile := ConfigFile{locationType: ConfigFileLocationRemote}
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
}
func TestParseConfigFile(t *testing.T) {
t.Run("Parse local YAML file with yml extension", func(t *testing.T) {
configFile, err := ParseConfigFile("config.yml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "config.yml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse local YAML file with yaml extension", func(t *testing.T) {
configFile, err := ParseConfigFile("config.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "config.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse local YAML file with uppercase extensions", func(t *testing.T) {
testCases := []string{"config.YML", "config.YAML", "config.Yml", "config.Yaml"}
for _, testCase := range testCases {
t.Run("Extension: "+testCase, func(t *testing.T) {
configFile, err := ParseConfigFile(testCase)
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, testCase, configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
}
})
t.Run("Parse remote HTTP YAML file", func(t *testing.T) {
configFile, err := ParseConfigFile("http://example.com/config.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "http://example.com/config.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
t.Run("Parse remote HTTPS YAML file", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/path/config.yml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "https://example.com/path/config.yml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
t.Run("Parse file with path separators", func(t *testing.T) {
configFile, err := ParseConfigFile("/path/to/config.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "/path/to/config.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse file without extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("config")
require.Error(t, err)
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
assert.Nil(t, configFile)
})
t.Run("Parse file with unsupported extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("config.json")
require.Error(t, err)
assert.IsType(t, &UnknownConfigFileTypeError{}, err)
assert.Contains(t, err.Error(), "json")
assert.Nil(t, configFile)
})
t.Run("Parse remote file with invalid URL returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("http://192.168.1.%30/config.yaml")
require.Error(t, err)
assert.IsType(t, &RemoteConfigFileParseError{}, err)
assert.Nil(t, configFile)
})
t.Run("Parse remote file without extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/config")
require.Error(t, err)
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
assert.Nil(t, configFile)
})
t.Run("Parse remote file with unsupported extension returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/config.txt")
require.Error(t, err)
assert.IsType(t, &UnknownConfigFileTypeError{}, err)
assert.Contains(t, err.Error(), "txt")
assert.Nil(t, configFile)
})
t.Run("Parse empty string returns error", func(t *testing.T) {
configFile, err := ParseConfigFile("")
require.Error(t, err)
assert.Equal(t, ErrConfigFileExtensionNotFound, err)
assert.Nil(t, configFile)
})
t.Run("Parse file with multiple dots in name", func(t *testing.T) {
configFile, err := ParseConfigFile("config.test.yaml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "config.test.yaml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
})
t.Run("Parse remote URL with query parameters and fragment", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com/path/config.yaml?version=1&format=yaml#section")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "https://example.com/path/config.yaml?version=1&format=yaml#section", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
t.Run("Parse remote URL with port", func(t *testing.T) {
configFile, err := ParseConfigFile("https://example.com:8080/config.yml")
require.NoError(t, err)
require.NotNil(t, configFile)
assert.Equal(t, "https://example.com:8080/config.yml", configFile.String())
assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
})
}

42
pkg/types/cookie.go Normal file
View File

@@ -0,0 +1,42 @@
package types
import "strings"
type Cookie KeyValue[string, []string]
type Cookies []Cookie
func (cookies Cookies) GetValue(key string) *[]string {
for i := range cookies {
if cookies[i].Key == key {
return &cookies[i].Value
}
}
return nil
}
func (cookies *Cookies) Append(cookie Cookie) {
if item := cookies.GetValue(cookie.Key); item != nil {
*item = append(*item, cookie.Value...)
} else {
*cookies = append(*cookies, cookie)
}
}
func (cookies *Cookies) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
cookies.Append(*ParseCookie(rawValue))
}
}
func ParseCookie(rawValue string) *Cookie {
parts := strings.SplitN(rawValue, "=", 2)
switch len(parts) {
case 1:
return &Cookie{Key: parts[0], Value: []string{""}}
case 2:
return &Cookie{Key: parts[0], Value: []string{parts[1]}}
default:
return &Cookie{Key: "", Value: []string{""}}
}
}

240
pkg/types/cookie_test.go Normal file
View File

@@ -0,0 +1,240 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCookies_GetValue(t *testing.T) {
t.Run("GetValue returns existing cookie value", func(t *testing.T) {
cookies := Cookies{
{Key: "session", Value: []string{"abc123"}},
{Key: "user", Value: []string{"john"}},
}
value := cookies.GetValue("session")
require.NotNil(t, value)
assert.Equal(t, []string{"abc123"}, *value)
})
t.Run("GetValue returns nil for non-existent cookie", func(t *testing.T) {
cookies := Cookies{
{Key: "session", Value: []string{"abc123"}},
}
value := cookies.GetValue("nonexistent")
assert.Nil(t, value)
})
t.Run("GetValue with empty cookies", func(t *testing.T) {
cookies := Cookies{}
value := cookies.GetValue("any")
assert.Nil(t, value)
})
t.Run("GetValue with multiple values", func(t *testing.T) {
cookies := Cookies{
{Key: "multi", Value: []string{"val1", "val2", "val3"}},
}
value := cookies.GetValue("multi")
require.NotNil(t, value)
assert.Equal(t, []string{"val1", "val2", "val3"}, *value)
})
t.Run("GetValue case sensitive", func(t *testing.T) {
cookies := Cookies{
{Key: "Cookie", Value: []string{"value"}},
}
value1 := cookies.GetValue("Cookie")
require.NotNil(t, value1)
assert.Equal(t, []string{"value"}, *value1)
value2 := cookies.GetValue("cookie")
assert.Nil(t, value2)
})
}
func TestCookies_Append(t *testing.T) {
t.Run("Append new cookie", func(t *testing.T) {
cookies := &Cookies{}
cookies.Append(Cookie{Key: "session", Value: []string{"abc123"}})
assert.Len(t, *cookies, 1)
assert.Equal(t, "session", (*cookies)[0].Key)
assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
})
t.Run("Append to existing cookie key", func(t *testing.T) {
cookies := &Cookies{
{Key: "session", Value: []string{"abc123"}},
}
cookies.Append(Cookie{Key: "session", Value: []string{"def456"}})
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{"abc123", "def456"}, (*cookies)[0].Value)
})
t.Run("Append different cookies", func(t *testing.T) {
cookies := &Cookies{}
cookies.Append(Cookie{Key: "session", Value: []string{"abc"}})
cookies.Append(Cookie{Key: "user", Value: []string{"john"}})
cookies.Append(Cookie{Key: "token", Value: []string{"xyz"}})
assert.Len(t, *cookies, 3)
})
t.Run("Append multiple values at once", func(t *testing.T) {
cookies := &Cookies{
{Key: "tags", Value: []string{"tag1"}},
}
cookies.Append(Cookie{Key: "tags", Value: []string{"tag2", "tag3"}})
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{"tag1", "tag2", "tag3"}, (*cookies)[0].Value)
})
t.Run("Append empty value", func(t *testing.T) {
cookies := &Cookies{}
cookies.Append(Cookie{Key: "empty", Value: []string{""}})
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{""}, (*cookies)[0].Value)
})
}
func TestCookies_Parse(t *testing.T) {
t.Run("Parse single cookie", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("session=abc123")
assert.Len(t, *cookies, 1)
assert.Equal(t, "session", (*cookies)[0].Key)
assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
})
t.Run("Parse multiple cookies", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("session=abc123", "user=john", "token=xyz789")
assert.Len(t, *cookies, 3)
assert.Equal(t, "session", (*cookies)[0].Key)
assert.Equal(t, "user", (*cookies)[1].Key)
assert.Equal(t, "token", (*cookies)[2].Key)
})
t.Run("Parse cookies with same key", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("pref=dark", "pref=large", "pref=en")
assert.Len(t, *cookies, 1)
assert.Equal(t, []string{"dark", "large", "en"}, (*cookies)[0].Value)
})
t.Run("Parse cookie without value", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("sessionid")
assert.Len(t, *cookies, 1)
assert.Equal(t, "sessionid", (*cookies)[0].Key)
assert.Equal(t, []string{""}, (*cookies)[0].Value)
})
t.Run("Parse cookie with empty value", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("empty=")
assert.Len(t, *cookies, 1)
assert.Equal(t, "empty", (*cookies)[0].Key)
assert.Equal(t, []string{""}, (*cookies)[0].Value)
})
t.Run("Parse cookie with multiple equals", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse("data=key=value=test")
assert.Len(t, *cookies, 1)
assert.Equal(t, "data", (*cookies)[0].Key)
assert.Equal(t, []string{"key=value=test"}, (*cookies)[0].Value)
})
t.Run("Parse no arguments", func(t *testing.T) {
cookies := &Cookies{}
cookies.Parse()
assert.Empty(t, *cookies)
})
t.Run("Parse with existing cookies", func(t *testing.T) {
cookies := &Cookies{
{Key: "existing", Value: []string{"value"}},
}
cookies.Parse("new=cookie")
assert.Len(t, *cookies, 2)
assert.Equal(t, "existing", (*cookies)[0].Key)
assert.Equal(t, "new", (*cookies)[1].Key)
})
}
func TestParseCookie(t *testing.T) {
t.Run("ParseCookie with key and value", func(t *testing.T) {
cookie := ParseCookie("session=abc123")
require.NotNil(t, cookie)
assert.Equal(t, "session", cookie.Key)
assert.Equal(t, []string{"abc123"}, cookie.Value)
})
t.Run("ParseCookie with only key", func(t *testing.T) {
cookie := ParseCookie("sessionid")
require.NotNil(t, cookie)
assert.Equal(t, "sessionid", cookie.Key)
assert.Equal(t, []string{""}, cookie.Value)
})
t.Run("ParseCookie with empty value", func(t *testing.T) {
cookie := ParseCookie("key=")
require.NotNil(t, cookie)
assert.Equal(t, "key", cookie.Key)
assert.Equal(t, []string{""}, cookie.Value)
})
t.Run("ParseCookie with multiple equals", func(t *testing.T) {
cookie := ParseCookie("data=base64=encoded=value")
require.NotNil(t, cookie)
assert.Equal(t, "data", cookie.Key)
assert.Equal(t, []string{"base64=encoded=value"}, cookie.Value)
})
t.Run("ParseCookie with empty string", func(t *testing.T) {
cookie := ParseCookie("")
require.NotNil(t, cookie)
assert.Empty(t, cookie.Key)
assert.Equal(t, []string{""}, cookie.Value)
})
t.Run("ParseCookie with spaces", func(t *testing.T) {
cookie := ParseCookie("key with spaces=value with spaces")
require.NotNil(t, cookie)
assert.Equal(t, "key with spaces", cookie.Key)
assert.Equal(t, []string{"value with spaces"}, cookie.Value)
})
t.Run("ParseCookie with special characters", func(t *testing.T) {
cookie := ParseCookie("key-._~=val!@#$%^&*()")
require.NotNil(t, cookie)
assert.Equal(t, "key-._~", cookie.Key)
assert.Equal(t, []string{"val!@#$%^&*()"}, cookie.Value)
})
t.Run("ParseCookie with URL encoded value", func(t *testing.T) {
cookie := ParseCookie("data=hello%20world%3D%26")
require.NotNil(t, cookie)
assert.Equal(t, "data", cookie.Key)
assert.Equal(t, []string{"hello%20world%3D%26"}, cookie.Value)
})
}

113
pkg/types/errors.go Normal file
View File

@@ -0,0 +1,113 @@
package types
import (
"errors"
"fmt"
"strings"
)
var (
// General
ErrNoError = errors.New("no error (internal)")
// CLI
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
ErrCLIUnexpectedArgs = errors.New("CLI received unexpected arguments")
// Config File
ErrConfigFileExtensionNotFound = errors.New("config file extension not found")
)
// ======================================== General ========================================
type FieldParseError struct {
Field string
Err error
}
func NewFieldParseError(field string, err error) *FieldParseError {
if err == nil {
err = ErrNoError
}
return &FieldParseError{field, err}
}
func (e FieldParseError) Error() string {
return fmt.Sprintf("Field '%s' parse failed: %v", e.Field, e.Err)
}
func (e FieldParseError) Unwrap() error {
return e.Err
}
type FieldParseErrors struct {
Errors []FieldParseError
}
func NewFieldParseErrors(fieldParseErrors []FieldParseError) FieldParseErrors {
return FieldParseErrors{fieldParseErrors}
}
func (e FieldParseErrors) Error() string {
if len(e.Errors) == 0 {
return "No field parse errors"
}
if len(e.Errors) == 1 {
return e.Errors[0].Error()
}
errorString := ""
for _, err := range e.Errors {
errorString += err.Error() + "\n"
}
errorString, _ = strings.CutSuffix(errorString, "\n")
return errorString
}
// ======================================== CLI ========================================
type CLIUnexpectedArgsError struct {
Args []string
}
func NewCLIUnexpectedArgsError(args []string) CLIUnexpectedArgsError {
return CLIUnexpectedArgsError{args}
}
func (e CLIUnexpectedArgsError) Error() string {
return fmt.Sprintf("CLI received unexpected arguments: %v", strings.Join(e.Args, ","))
}
// ======================================== Config File ========================================
type RemoteConfigFileParseError struct {
error error
}
func NewRemoteConfigFileParseError(err error) *RemoteConfigFileParseError {
if err == nil {
err = ErrNoError
}
return &RemoteConfigFileParseError{err}
}
func (e RemoteConfigFileParseError) Error() string {
return "Remote config file parse error: " + e.error.Error()
}
func (e RemoteConfigFileParseError) Unwrap() error {
return e.error
}
type UnknownConfigFileTypeError struct {
Type string
}
func NewUnknownConfigFileTypeError(_type string) *UnknownConfigFileTypeError {
return &UnknownConfigFileTypeError{_type}
}
func (e UnknownConfigFileTypeError) Error() string {
return "Unknown config file type: " + e.Type
}

284
pkg/types/errors_test.go Normal file
View File

@@ -0,0 +1,284 @@
package types
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFieldParseError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
originalErr := errors.New("invalid value")
fieldErr := NewFieldParseError("username", originalErr)
expected := "Field 'username' parse failed: invalid value"
assert.Equal(t, expected, fieldErr.Error())
})
t.Run("Error with empty field name", func(t *testing.T) {
originalErr := errors.New("test error")
fieldErr := NewFieldParseError("", originalErr)
expected := "Field '' parse failed: test error"
assert.Equal(t, expected, fieldErr.Error())
})
t.Run("Error with nil underlying error", func(t *testing.T) {
fieldErr := NewFieldParseError("field", nil)
expected := "Field 'field' parse failed: no error (internal)"
assert.Equal(t, expected, fieldErr.Error())
})
}
func TestFieldParseError_Unwrap(t *testing.T) {
t.Run("Unwrap returns original error", func(t *testing.T) {
originalErr := errors.New("original error")
fieldErr := NewFieldParseError("field", originalErr)
assert.Equal(t, originalErr, fieldErr.Unwrap())
})
t.Run("Unwrap with nil error", func(t *testing.T) {
fieldErr := NewFieldParseError("field", nil)
assert.Equal(t, ErrNoError, fieldErr.Unwrap())
})
}
func TestNewFieldParseError(t *testing.T) {
t.Run("Creates FieldParseError with correct values", func(t *testing.T) {
originalErr := errors.New("test error")
fieldErr := NewFieldParseError("testField", originalErr)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, originalErr, fieldErr.Err)
})
t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) {
fieldErr := NewFieldParseError("testField", nil)
assert.Equal(t, "testField", fieldErr.Field)
assert.Equal(t, ErrNoError, fieldErr.Err)
})
}
func TestFieldParseErrors_Error(t *testing.T) {
t.Run("Error with no errors returns default message", func(t *testing.T) {
fieldErrors := NewFieldParseErrors([]FieldParseError{})
assert.Equal(t, "No field parse errors", fieldErrors.Error())
})
t.Run("Error with single error returns single error message", func(t *testing.T) {
fieldErr := *NewFieldParseError("field1", errors.New("error1"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr})
expected := "Field 'field1' parse failed: error1"
assert.Equal(t, expected, fieldErrors.Error())
})
t.Run("Error with multiple errors returns concatenated messages", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
fieldErr3 := *NewFieldParseError("field3", errors.New("error3"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2, fieldErr3})
expected := "Field 'field1' parse failed: error1\nField 'field2' parse failed: error2\nField 'field3' parse failed: error3"
assert.Equal(t, expected, fieldErrors.Error())
})
t.Run("Error with two errors", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("username", errors.New("too short"))
fieldErr2 := *NewFieldParseError("email", errors.New("invalid format"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
expected := "Field 'username' parse failed: too short\nField 'email' parse failed: invalid format"
assert.Equal(t, expected, fieldErrors.Error())
})
}
func TestNewFieldParseErrors(t *testing.T) {
t.Run("Creates FieldParseErrors with correct values", func(t *testing.T) {
fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
assert.Len(t, fieldErrors.Errors, 2)
assert.Equal(t, fieldErr1, fieldErrors.Errors[0])
assert.Equal(t, fieldErr2, fieldErrors.Errors[1])
})
t.Run("Creates FieldParseErrors with empty slice", func(t *testing.T) {
fieldErrors := NewFieldParseErrors([]FieldParseError{})
assert.Empty(t, fieldErrors.Errors)
})
}
func TestCLIUnexpectedArgsError_Error(t *testing.T) {
t.Run("Error with single argument", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{"arg1"})
expected := "CLI received unexpected arguments: arg1"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with multiple arguments", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{"arg1", "arg2", "arg3"})
expected := "CLI received unexpected arguments: arg1,arg2,arg3"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with empty arguments", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{})
expected := "CLI received unexpected arguments: "
assert.Equal(t, expected, err.Error())
})
t.Run("Error with arguments containing special characters", func(t *testing.T) {
err := NewCLIUnexpectedArgsError([]string{"--flag", "value with spaces", "-x"})
expected := "CLI received unexpected arguments: --flag,value with spaces,-x"
assert.Equal(t, expected, err.Error())
})
}
func TestNewCLIUnexpectedArgsError(t *testing.T) {
t.Run("Creates CLIUnexpectedArgsError with correct values", func(t *testing.T) {
args := []string{"arg1", "arg2"}
err := NewCLIUnexpectedArgsError(args)
assert.Equal(t, args, err.Args)
})
}
func TestRemoteConfigFileParseError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
originalErr := errors.New("invalid URL")
err := NewRemoteConfigFileParseError(originalErr)
expected := "Remote config file parse error: invalid URL"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with nil underlying error", func(t *testing.T) {
err := NewRemoteConfigFileParseError(nil)
expected := "Remote config file parse error: no error (internal)"
assert.Equal(t, expected, err.Error())
})
}
func TestRemoteConfigFileParseError_Unwrap(t *testing.T) {
t.Run("Unwrap returns original error", func(t *testing.T) {
originalErr := errors.New("original error")
err := NewRemoteConfigFileParseError(originalErr)
assert.Equal(t, originalErr, err.Unwrap())
})
t.Run("Unwrap with nil error", func(t *testing.T) {
err := NewRemoteConfigFileParseError(nil)
assert.Equal(t, ErrNoError, err.Unwrap())
})
}
func TestNewRemoteConfigFileParseError(t *testing.T) {
t.Run("Creates RemoteConfigFileParseError with correct values", func(t *testing.T) {
originalErr := errors.New("test error")
err := NewRemoteConfigFileParseError(originalErr)
assert.Equal(t, originalErr, err.error)
})
t.Run("Creates RemoteConfigFileParseError with ErrNoError when nil passed", func(t *testing.T) {
err := NewRemoteConfigFileParseError(nil)
assert.Equal(t, ErrNoError, err.error)
})
}
func TestUnknownConfigFileTypeError_Error(t *testing.T) {
t.Run("Error returns formatted message", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("json")
expected := "Unknown config file type: json"
assert.Equal(t, expected, err.Error())
})
t.Run("Error with empty type", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("")
expected := "Unknown config file type: "
assert.Equal(t, expected, err.Error())
})
t.Run("Error with special characters in type", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("type.with.dots")
expected := "Unknown config file type: type.with.dots"
assert.Equal(t, expected, err.Error())
})
}
func TestNewUnknownConfigFileTypeError(t *testing.T) {
t.Run("Creates UnknownConfigFileTypeError with correct values", func(t *testing.T) {
err := NewUnknownConfigFileTypeError("xml")
assert.Equal(t, "xml", err.Type)
})
}
func TestErrorConstants(t *testing.T) {
t.Run("ErrNoError has correct message", func(t *testing.T) {
expected := "no error (internal)"
assert.Equal(t, expected, ErrNoError.Error())
})
t.Run("ErrCLINoArgs has correct message", func(t *testing.T) {
expected := "CLI expects arguments but received none"
assert.Equal(t, expected, ErrCLINoArgs.Error())
})
t.Run("ErrCLIUnexpectedArgs has correct message", func(t *testing.T) {
expected := "CLI received unexpected arguments"
assert.Equal(t, expected, ErrCLIUnexpectedArgs.Error())
})
t.Run("ErrConfigFileExtensionNotFound has correct message", func(t *testing.T) {
expected := "config file extension not found"
assert.Equal(t, expected, ErrConfigFileExtensionNotFound.Error())
})
}
func TestErrorImplementsErrorInterface(t *testing.T) {
t.Run("FieldParseError implements error interface", func(t *testing.T) {
var err error = NewFieldParseError("field", errors.New("test"))
assert.Error(t, err)
})
t.Run("FieldParseErrors implements error interface", func(t *testing.T) {
var err error = NewFieldParseErrors([]FieldParseError{})
assert.Error(t, err)
})
t.Run("CLIUnexpectedArgsError implements error interface", func(t *testing.T) {
var err error = NewCLIUnexpectedArgsError([]string{})
assert.Error(t, err)
})
t.Run("RemoteConfigFileParseError implements error interface", func(t *testing.T) {
var err error = NewRemoteConfigFileParseError(errors.New("test"))
assert.Error(t, err)
})
t.Run("UnknownConfigFileTypeError implements error interface", func(t *testing.T) {
var err error = NewUnknownConfigFileTypeError("test")
assert.Error(t, err)
})
}

52
pkg/types/header.go Normal file
View File

@@ -0,0 +1,52 @@
package types
import "strings"
type Header KeyValue[string, []string]
type Headers []Header
// Has checks if a header with the given key exists.
func (headers Headers) Has(key string) bool {
for i := range headers {
if headers[i].Key == key {
return true
}
}
return false
}
func (headers Headers) GetValue(key string) *[]string {
for i := range headers {
if headers[i].Key == key {
return &headers[i].Value
}
}
return nil
}
func (headers *Headers) Append(header Header) {
if item := headers.GetValue(header.Key); item != nil {
*item = append(*item, header.Value...)
} else {
*headers = append(*headers, header)
}
}
func (headers *Headers) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
headers.Append(*ParseHeader(rawValue))
}
}
func ParseHeader(rawValue string) *Header {
parts := strings.SplitN(rawValue, ": ", 2)
switch len(parts) {
case 1:
return &Header{Key: parts[0], Value: []string{""}}
case 2:
return &Header{Key: parts[0], Value: []string{parts[1]}}
default:
return &Header{Key: "", Value: []string{""}}
}
}

277
pkg/types/header_test.go Normal file
View File

@@ -0,0 +1,277 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHeaders_Has(t *testing.T) {
t.Run("Has returns true for existing header", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
{Key: "Authorization", Value: []string{"Bearer token"}},
}
assert.True(t, headers.Has("Content-Type"))
assert.True(t, headers.Has("Authorization"))
})
t.Run("Has returns false for non-existent header", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
}
assert.False(t, headers.Has("Authorization"))
assert.False(t, headers.Has("X-Custom-Header"))
})
t.Run("Has with empty headers", func(t *testing.T) {
headers := Headers{}
assert.False(t, headers.Has("Any-Header"))
})
t.Run("Has is case sensitive", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"text/html"}},
}
assert.True(t, headers.Has("Content-Type"))
assert.False(t, headers.Has("content-type"))
assert.False(t, headers.Has("CONTENT-TYPE"))
})
}
func TestHeaders_GetValue(t *testing.T) {
t.Run("GetValue returns existing header value", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
{Key: "Accept", Value: []string{"text/html"}},
}
value := headers.GetValue("Content-Type")
require.NotNil(t, value)
assert.Equal(t, []string{"application/json"}, *value)
})
t.Run("GetValue returns nil for non-existent header", func(t *testing.T) {
headers := Headers{
{Key: "Content-Type", Value: []string{"application/json"}},
}
value := headers.GetValue("Authorization")
assert.Nil(t, value)
})
t.Run("GetValue with empty headers", func(t *testing.T) {
headers := Headers{}
value := headers.GetValue("Any-Header")
assert.Nil(t, value)
})
t.Run("GetValue with multiple values", func(t *testing.T) {
headers := Headers{
{Key: "Accept", Value: []string{"text/html", "application/xml", "application/json"}},
}
value := headers.GetValue("Accept")
require.NotNil(t, value)
assert.Equal(t, []string{"text/html", "application/xml", "application/json"}, *value)
})
t.Run("GetValue is case sensitive", func(t *testing.T) {
headers := Headers{
{Key: "X-Custom-Header", Value: []string{"value"}},
}
value1 := headers.GetValue("X-Custom-Header")
require.NotNil(t, value1)
assert.Equal(t, []string{"value"}, *value1)
value2 := headers.GetValue("x-custom-header")
assert.Nil(t, value2)
})
}
func TestHeaders_Append(t *testing.T) {
t.Run("Append new header", func(t *testing.T) {
headers := &Headers{}
headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
assert.Len(t, *headers, 1)
assert.Equal(t, "Content-Type", (*headers)[0].Key)
assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
})
t.Run("Append to existing header key", func(t *testing.T) {
headers := &Headers{
{Key: "Accept", Value: []string{"text/html"}},
}
headers.Append(Header{Key: "Accept", Value: []string{"application/json"}})
assert.Len(t, *headers, 1)
assert.Equal(t, []string{"text/html", "application/json"}, (*headers)[0].Value)
})
t.Run("Append different headers", func(t *testing.T) {
headers := &Headers{}
headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
headers.Append(Header{Key: "Authorization", Value: []string{"Bearer token"}})
headers.Append(Header{Key: "Accept", Value: []string{"*/*"}})
assert.Len(t, *headers, 3)
})
t.Run("Append multiple values at once", func(t *testing.T) {
headers := &Headers{
{Key: "Accept-Language", Value: []string{"en"}},
}
headers.Append(Header{Key: "Accept-Language", Value: []string{"fr", "de"}})
assert.Len(t, *headers, 1)
assert.Equal(t, []string{"en", "fr", "de"}, (*headers)[0].Value)
})
t.Run("Append empty value", func(t *testing.T) {
headers := &Headers{}
headers.Append(Header{Key: "Empty-Header", Value: []string{""}})
assert.Len(t, *headers, 1)
assert.Equal(t, []string{""}, (*headers)[0].Value)
})
}
func TestHeaders_Parse(t *testing.T) {
t.Run("Parse single header", func(t *testing.T) {
headers := &Headers{}
headers.Parse("Content-Type: application/json")
assert.Len(t, *headers, 1)
assert.Equal(t, "Content-Type", (*headers)[0].Key)
assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
})
t.Run("Parse multiple headers", func(t *testing.T) {
headers := &Headers{}
headers.Parse("Content-Type: application/json", "Authorization: Bearer token", "Accept: */*")
assert.Len(t, *headers, 3)
assert.Equal(t, "Content-Type", (*headers)[0].Key)
assert.Equal(t, "Authorization", (*headers)[1].Key)
assert.Equal(t, "Accept", (*headers)[2].Key)
})
t.Run("Parse headers with same key", func(t *testing.T) {
headers := &Headers{}
headers.Parse("Accept: text/html", "Accept: application/json", "Accept: application/xml")
assert.Len(t, *headers, 1)
assert.Equal(t, []string{"text/html", "application/json", "application/xml"}, (*headers)[0].Value)
})
t.Run("Parse header without value", func(t *testing.T) {
headers := &Headers{}
headers.Parse("X-Empty-Header")
assert.Len(t, *headers, 1)
assert.Equal(t, "X-Empty-Header", (*headers)[0].Key)
assert.Equal(t, []string{""}, (*headers)[0].Value)
})
t.Run("Parse header with empty value", func(t *testing.T) {
headers := &Headers{}
headers.Parse("X-Empty: ")
assert.Len(t, *headers, 1)
assert.Equal(t, "X-Empty", (*headers)[0].Key)
assert.Equal(t, []string{""}, (*headers)[0].Value)
})
t.Run("Parse header with multiple colons", func(t *testing.T) {
headers := &Headers{}
headers.Parse("X-Time: 12:34:56")
assert.Len(t, *headers, 1)
assert.Equal(t, "X-Time", (*headers)[0].Key)
assert.Equal(t, []string{"12:34:56"}, (*headers)[0].Value)
})
t.Run("Parse no arguments", func(t *testing.T) {
headers := &Headers{}
headers.Parse()
assert.Empty(t, *headers)
})
t.Run("Parse with existing headers", func(t *testing.T) {
headers := &Headers{
{Key: "Existing", Value: []string{"value"}},
}
headers.Parse("New: header")
assert.Len(t, *headers, 2)
assert.Equal(t, "Existing", (*headers)[0].Key)
assert.Equal(t, "New", (*headers)[1].Key)
})
}
func TestParseHeader(t *testing.T) {
t.Run("ParseHeader with key and value", func(t *testing.T) {
header := ParseHeader("Content-Type: application/json")
require.NotNil(t, header)
assert.Equal(t, "Content-Type", header.Key)
assert.Equal(t, []string{"application/json"}, header.Value)
})
t.Run("ParseHeader with only key", func(t *testing.T) {
header := ParseHeader("X-Header")
require.NotNil(t, header)
assert.Equal(t, "X-Header", header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with empty value", func(t *testing.T) {
header := ParseHeader("Key: ")
require.NotNil(t, header)
assert.Equal(t, "Key", header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with multiple colons", func(t *testing.T) {
header := ParseHeader("X-URL: https://example.com:8080/path")
require.NotNil(t, header)
assert.Equal(t, "X-URL", header.Key)
assert.Equal(t, []string{"https://example.com:8080/path"}, header.Value)
})
t.Run("ParseHeader with empty string", func(t *testing.T) {
header := ParseHeader("")
require.NotNil(t, header)
assert.Empty(t, header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with spaces in value", func(t *testing.T) {
header := ParseHeader("User-Agent: Mozilla/5.0 (Windows NT 10.0)")
require.NotNil(t, header)
assert.Equal(t, "User-Agent", header.Key)
assert.Equal(t, []string{"Mozilla/5.0 (Windows NT 10.0)"}, header.Value)
})
t.Run("ParseHeader without colon-space separator", func(t *testing.T) {
header := ParseHeader("Content-Type:application/json")
require.NotNil(t, header)
assert.Equal(t, "Content-Type:application/json", header.Key)
assert.Equal(t, []string{""}, header.Value)
})
t.Run("ParseHeader with trailing spaces", func(t *testing.T) {
header := ParseHeader("Header: value with spaces ")
require.NotNil(t, header)
assert.Equal(t, "Header", header.Key)
assert.Equal(t, []string{"value with spaces "}, header.Value)
})
}

6
pkg/types/key_value.go Normal file
View File

@@ -0,0 +1,6 @@
package types
type KeyValue[K comparable, V any] struct {
Key K
Value V
}

42
pkg/types/param.go Normal file
View File

@@ -0,0 +1,42 @@
package types
import "strings"
type Param KeyValue[string, []string]
type Params []Param
func (params Params) GetValue(key string) *[]string {
for i := range params {
if params[i].Key == key {
return &params[i].Value
}
}
return nil
}
func (params *Params) Append(param Param) {
if item := params.GetValue(param.Key); item != nil {
*item = append(*item, param.Value...)
} else {
*params = append(*params, param)
}
}
func (params *Params) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
params.Append(*ParseParam(rawValue))
}
}
func ParseParam(rawValue string) *Param {
parts := strings.SplitN(rawValue, "=", 2)
switch len(parts) {
case 1:
return &Param{Key: parts[0], Value: []string{""}}
case 2:
return &Param{Key: parts[0], Value: []string{parts[1]}}
default:
return &Param{Key: "", Value: []string{""}}
}
}

281
pkg/types/param_test.go Normal file
View File

@@ -0,0 +1,281 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParams_GetValue(t *testing.T) {
t.Run("GetValue returns existing parameter value", func(t *testing.T) {
params := Params{
{Key: "name", Value: []string{"john"}},
{Key: "age", Value: []string{"25"}},
}
value := params.GetValue("name")
require.NotNil(t, value)
assert.Equal(t, []string{"john"}, *value)
})
t.Run("GetValue returns nil for non-existent parameter", func(t *testing.T) {
params := Params{
{Key: "name", Value: []string{"john"}},
}
value := params.GetValue("nonexistent")
assert.Nil(t, value)
})
t.Run("GetValue with empty params", func(t *testing.T) {
params := Params{}
value := params.GetValue("any")
assert.Nil(t, value)
})
t.Run("GetValue with multiple values", func(t *testing.T) {
params := Params{
{Key: "tags", Value: []string{"go", "test", "api"}},
}
value := params.GetValue("tags")
require.NotNil(t, value)
assert.Equal(t, []string{"go", "test", "api"}, *value)
})
t.Run("GetValue case sensitive", func(t *testing.T) {
params := Params{
{Key: "Name", Value: []string{"value"}},
}
value1 := params.GetValue("Name")
require.NotNil(t, value1)
assert.Equal(t, []string{"value"}, *value1)
value2 := params.GetValue("name")
assert.Nil(t, value2)
})
}
func TestParams_Append(t *testing.T) {
t.Run("Append new parameter", func(t *testing.T) {
params := &Params{}
params.Append(Param{Key: "name", Value: []string{"john"}})
assert.Len(t, *params, 1)
assert.Equal(t, "name", (*params)[0].Key)
assert.Equal(t, []string{"john"}, (*params)[0].Value)
})
t.Run("Append to existing parameter key", func(t *testing.T) {
params := &Params{
{Key: "tags", Value: []string{"go"}},
}
params.Append(Param{Key: "tags", Value: []string{"test"}})
assert.Len(t, *params, 1)
assert.Equal(t, []string{"go", "test"}, (*params)[0].Value)
})
t.Run("Append different parameters", func(t *testing.T) {
params := &Params{}
params.Append(Param{Key: "name", Value: []string{"john"}})
params.Append(Param{Key: "age", Value: []string{"25"}})
params.Append(Param{Key: "city", Value: []string{"NYC"}})
assert.Len(t, *params, 3)
})
t.Run("Append multiple values at once", func(t *testing.T) {
params := &Params{
{Key: "colors", Value: []string{"red"}},
}
params.Append(Param{Key: "colors", Value: []string{"blue", "green"}})
assert.Len(t, *params, 1)
assert.Equal(t, []string{"red", "blue", "green"}, (*params)[0].Value)
})
t.Run("Append empty value", func(t *testing.T) {
params := &Params{}
params.Append(Param{Key: "empty", Value: []string{""}})
assert.Len(t, *params, 1)
assert.Equal(t, []string{""}, (*params)[0].Value)
})
}
func TestParams_Parse(t *testing.T) {
t.Run("Parse single parameter", func(t *testing.T) {
params := &Params{}
params.Parse("name=john")
assert.Len(t, *params, 1)
assert.Equal(t, "name", (*params)[0].Key)
assert.Equal(t, []string{"john"}, (*params)[0].Value)
})
t.Run("Parse multiple parameters", func(t *testing.T) {
params := &Params{}
params.Parse("name=john", "age=25", "city=NYC")
assert.Len(t, *params, 3)
assert.Equal(t, "name", (*params)[0].Key)
assert.Equal(t, "age", (*params)[1].Key)
assert.Equal(t, "city", (*params)[2].Key)
})
t.Run("Parse parameters with same key", func(t *testing.T) {
params := &Params{}
params.Parse("filter=name", "filter=age", "filter=city")
assert.Len(t, *params, 1)
assert.Equal(t, []string{"name", "age", "city"}, (*params)[0].Value)
})
t.Run("Parse parameter without value", func(t *testing.T) {
params := &Params{}
params.Parse("debug")
assert.Len(t, *params, 1)
assert.Equal(t, "debug", (*params)[0].Key)
assert.Equal(t, []string{""}, (*params)[0].Value)
})
t.Run("Parse parameter with empty value", func(t *testing.T) {
params := &Params{}
params.Parse("empty=")
assert.Len(t, *params, 1)
assert.Equal(t, "empty", (*params)[0].Key)
assert.Equal(t, []string{""}, (*params)[0].Value)
})
t.Run("Parse parameter with multiple equals", func(t *testing.T) {
params := &Params{}
params.Parse("equation=x=y+z")
assert.Len(t, *params, 1)
assert.Equal(t, "equation", (*params)[0].Key)
assert.Equal(t, []string{"x=y+z"}, (*params)[0].Value)
})
t.Run("Parse no arguments", func(t *testing.T) {
params := &Params{}
params.Parse()
assert.Empty(t, *params)
})
t.Run("Parse with existing parameters", func(t *testing.T) {
params := &Params{
{Key: "existing", Value: []string{"value"}},
}
params.Parse("new=param")
assert.Len(t, *params, 2)
assert.Equal(t, "existing", (*params)[0].Key)
assert.Equal(t, "new", (*params)[1].Key)
})
t.Run("Parse URL-encoded values", func(t *testing.T) {
params := &Params{}
params.Parse("query=hello%20world", "special=%21%40%23")
assert.Len(t, *params, 2)
assert.Equal(t, []string{"hello%20world"}, (*params)[0].Value)
assert.Equal(t, []string{"%21%40%23"}, (*params)[1].Value)
})
}
func TestParseParam(t *testing.T) {
t.Run("ParseParam with key and value", func(t *testing.T) {
param := ParseParam("name=john")
require.NotNil(t, param)
assert.Equal(t, "name", param.Key)
assert.Equal(t, []string{"john"}, param.Value)
})
t.Run("ParseParam with only key", func(t *testing.T) {
param := ParseParam("debug")
require.NotNil(t, param)
assert.Equal(t, "debug", param.Key)
assert.Equal(t, []string{""}, param.Value)
})
t.Run("ParseParam with empty value", func(t *testing.T) {
param := ParseParam("key=")
require.NotNil(t, param)
assert.Equal(t, "key", param.Key)
assert.Equal(t, []string{""}, param.Value)
})
t.Run("ParseParam with multiple equals", func(t *testing.T) {
param := ParseParam("data=key=value=test")
require.NotNil(t, param)
assert.Equal(t, "data", param.Key)
assert.Equal(t, []string{"key=value=test"}, param.Value)
})
t.Run("ParseParam with empty string", func(t *testing.T) {
param := ParseParam("")
require.NotNil(t, param)
assert.Empty(t, param.Key)
assert.Equal(t, []string{""}, param.Value)
})
t.Run("ParseParam with spaces", func(t *testing.T) {
param := ParseParam("key with spaces=value with spaces")
require.NotNil(t, param)
assert.Equal(t, "key with spaces", param.Key)
assert.Equal(t, []string{"value with spaces"}, param.Value)
})
t.Run("ParseParam with special characters", func(t *testing.T) {
param := ParseParam("key-._~=val!@#$%^&*()")
require.NotNil(t, param)
assert.Equal(t, "key-._~", param.Key)
assert.Equal(t, []string{"val!@#$%^&*()"}, param.Value)
})
t.Run("ParseParam with numeric values", func(t *testing.T) {
param := ParseParam("count=42")
require.NotNil(t, param)
assert.Equal(t, "count", param.Key)
assert.Equal(t, []string{"42"}, param.Value)
})
t.Run("ParseParam with boolean-like values", func(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"active=true", "true"},
{"enabled=false", "false"},
{"visible=1", "1"},
{"hidden=0", "0"},
}
for _, testCase := range testCases {
param := ParseParam(testCase.input)
require.NotNil(t, param)
assert.Equal(t, []string{testCase.expected}, param.Value)
}
})
t.Run("ParseParam with URL-encoded value", func(t *testing.T) {
param := ParseParam("message=hello%20world")
require.NotNil(t, param)
assert.Equal(t, "message", param.Key)
assert.Equal(t, []string{"hello%20world"}, param.Value)
})
t.Run("ParseParam with JSON-like value", func(t *testing.T) {
param := ParseParam(`data={"key":"value"}`)
require.NotNil(t, param)
assert.Equal(t, "data", param.Key)
assert.Equal(t, []string{`{"key":"value"}`}, param.Value)
})
}

38
pkg/types/proxy.go Normal file
View File

@@ -0,0 +1,38 @@
package types
import (
"fmt"
"net/url"
)
type Proxy url.URL
func (proxy Proxy) String() string {
return (*url.URL)(&proxy).String()
}
type Proxies []Proxy
func (proxies *Proxies) Append(proxy Proxy) {
*proxies = append(*proxies, proxy)
}
func (proxies *Proxies) Parse(rawValue string) error {
parsedProxy, err := ParseProxy(rawValue)
if err != nil {
return err
}
proxies.Append(*parsedProxy)
return nil
}
func ParseProxy(rawValue string) (*Proxy, error) {
urlParsed, err := url.Parse(rawValue)
if err != nil {
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
}
proxyParsed := Proxy(*urlParsed)
return &proxyParsed, nil
}

285
pkg/types/proxy_test.go Normal file
View File

@@ -0,0 +1,285 @@
package types
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProxy_String(t *testing.T) {
t.Run("Proxy String returns correct URL", func(t *testing.T) {
u, err := url.Parse("http://proxy.example.com:8080")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
})
t.Run("Proxy String with HTTPS", func(t *testing.T) {
u, err := url.Parse("https://secure-proxy.example.com:443")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
})
t.Run("Proxy String with authentication", func(t *testing.T) {
u, err := url.Parse("http://user:pass@proxy.example.com:8080")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://user:pass@proxy.example.com:8080", proxy.String())
})
t.Run("Proxy String with path", func(t *testing.T) {
u, err := url.Parse("http://proxy.example.com:8080/proxy/path")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://proxy.example.com:8080/proxy/path", proxy.String())
})
t.Run("Proxy String with query params", func(t *testing.T) {
u, err := url.Parse("http://proxy.example.com:8080/?timeout=30&retry=3")
require.NoError(t, err)
proxy := Proxy(*u)
assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
})
}
func TestProxies_Append(t *testing.T) {
t.Run("Append single proxy", func(t *testing.T) {
proxies := &Proxies{}
u, err := url.Parse("http://proxy1.example.com:8080")
require.NoError(t, err)
proxy := Proxy(*u)
proxies.Append(proxy)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
})
t.Run("Append multiple proxies", func(t *testing.T) {
proxies := &Proxies{}
url1, err := url.Parse("http://proxy1.example.com:8080")
require.NoError(t, err)
url2, err := url.Parse("http://proxy2.example.com:8081")
require.NoError(t, err)
url3, err := url.Parse("https://proxy3.example.com:443")
require.NoError(t, err)
proxies.Append(Proxy(*url1))
proxies.Append(Proxy(*url2))
proxies.Append(Proxy(*url3))
assert.Len(t, *proxies, 3)
assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
assert.Equal(t, "http://proxy2.example.com:8081", (*proxies)[1].String())
assert.Equal(t, "https://proxy3.example.com:443", (*proxies)[2].String())
})
t.Run("Append to existing proxies", func(t *testing.T) {
existingURL, err := url.Parse("http://existing.example.com:8080")
require.NoError(t, err)
proxies := &Proxies{Proxy(*existingURL)}
newURL, err := url.Parse("http://new.example.com:8081")
require.NoError(t, err)
proxies.Append(Proxy(*newURL))
assert.Len(t, *proxies, 2)
assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
})
}
func TestProxies_Parse(t *testing.T) {
t.Run("Parse valid proxy URL", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("http://proxy.example.com:8080")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://proxy.example.com:8080", (*proxies)[0].String())
})
t.Run("Parse HTTPS proxy URL", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("https://secure-proxy.example.com:443")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "https://secure-proxy.example.com:443", (*proxies)[0].String())
})
t.Run("Parse proxy URL with authentication", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("http://user:pass@proxy.example.com:8080")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://user:pass@proxy.example.com:8080", (*proxies)[0].String())
})
t.Run("Parse invalid proxy URL", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("://invalid-url")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse proxy URL")
assert.Empty(t, *proxies)
})
t.Run("Parse empty string", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Empty(t, (*proxies)[0].String())
})
t.Run("Parse to existing proxies", func(t *testing.T) {
existingURL, err := url.Parse("http://existing.example.com:8080")
require.NoError(t, err)
proxies := &Proxies{Proxy(*existingURL)}
err = proxies.Parse("http://new.example.com:8081")
require.NoError(t, err)
assert.Len(t, *proxies, 2)
assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
})
t.Run("Parse proxy with special characters", func(t *testing.T) {
proxies := &Proxies{}
err := proxies.Parse("http://proxy.example.com:8080/path?param=value&other=test")
require.NoError(t, err)
assert.Len(t, *proxies, 1)
assert.Equal(t, "http://proxy.example.com:8080/path?param=value&other=test", (*proxies)[0].String())
})
}
func TestParseProxy(t *testing.T) {
t.Run("ParseProxy with valid HTTP URL", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
})
t.Run("ParseProxy with valid HTTPS URL", func(t *testing.T) {
proxy, err := ParseProxy("https://secure-proxy.example.com:443")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
})
t.Run("ParseProxy with authentication", func(t *testing.T) {
proxy, err := ParseProxy("http://user:password@proxy.example.com:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://user:password@proxy.example.com:8080", proxy.String())
})
t.Run("ParseProxy with path", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080/proxy/endpoint")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080/proxy/endpoint", proxy.String())
})
t.Run("ParseProxy with query parameters", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080/?timeout=30&retry=3")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
})
t.Run("ParseProxy with malformed URL", func(t *testing.T) {
proxy, err := ParseProxy("://malformed-url")
require.Error(t, err)
assert.Nil(t, proxy)
assert.Contains(t, err.Error(), "failed to parse proxy URL")
})
t.Run("ParseProxy with empty string", func(t *testing.T) {
proxy, err := ParseProxy("")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Empty(t, proxy.String())
})
t.Run("ParseProxy with localhost", func(t *testing.T) {
proxy, err := ParseProxy("http://localhost:3128")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://localhost:3128", proxy.String())
})
t.Run("ParseProxy with IP address", func(t *testing.T) {
proxy, err := ParseProxy("http://192.168.1.100:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://192.168.1.100:8080", proxy.String())
})
t.Run("ParseProxy without scheme", func(t *testing.T) {
proxy, err := ParseProxy("proxy.example.com:8080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "proxy.example.com:8080", proxy.String())
})
t.Run("ParseProxy with SOCKS protocol", func(t *testing.T) {
proxy, err := ParseProxy("socks5://proxy.example.com:1080")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "socks5://proxy.example.com:1080", proxy.String())
})
t.Run("ParseProxy preserves URL components", func(t *testing.T) {
rawURL := "http://user:pass@proxy.example.com:8080/path?param=value#fragment"
proxy, err := ParseProxy(rawURL)
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, rawURL, proxy.String())
})
t.Run("ParseProxy with percent encoding", func(t *testing.T) {
proxy, err := ParseProxy("http://proxy.example.com:8080/path%20with%20spaces")
require.NoError(t, err)
require.NotNil(t, proxy)
assert.Equal(t, "http://proxy.example.com:8080/path%20with%20spaces", proxy.String())
})
t.Run("ParseProxy error message format", func(t *testing.T) {
_, err := ParseProxy("http://[invalid-ipv6")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse proxy URL:")
assert.Contains(t, err.Error(), "missing ']' in host")
})
}

5
pkg/utils/convert.go Normal file
View File

@@ -0,0 +1,5 @@
package utils
func ToPtr[T any](value T) *T {
return &value
}

155
pkg/utils/convert_test.go Normal file
View File

@@ -0,0 +1,155 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToPtr(t *testing.T) {
t.Run("ToPtr with int", func(t *testing.T) {
value := 42
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.NotSame(t, &value, ptr, "Should return a new pointer")
})
t.Run("ToPtr with string", func(t *testing.T) {
value := "test string"
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr with bool", func(t *testing.T) {
value := true
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr with float64", func(t *testing.T) {
value := 3.14159
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.InEpsilon(t, value, *ptr, 0.0001)
})
t.Run("ToPtr with struct", func(t *testing.T) {
type TestStruct struct {
Field1 string
Field2 int
}
value := TestStruct{Field1: "test", Field2: 123}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Equal(t, "test", ptr.Field1)
assert.Equal(t, 123, ptr.Field2)
})
t.Run("ToPtr with slice", func(t *testing.T) {
value := []int{1, 2, 3}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Len(t, *ptr, 3)
})
t.Run("ToPtr with map", func(t *testing.T) {
value := map[string]int{"one": 1, "two": 2}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Len(t, *ptr, 2)
})
t.Run("ToPtr with nil interface", func(t *testing.T) {
var value any = nil
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Nil(t, *ptr)
})
t.Run("ToPtr with pointer", func(t *testing.T) {
originalValue := 42
originalPtr := &originalValue
ptr := ToPtr(originalPtr)
require.NotNil(t, ptr)
assert.Equal(t, originalPtr, *ptr)
assert.NotSame(t, originalPtr, ptr, "Should return a pointer to pointer")
})
t.Run("ToPtr with uint", func(t *testing.T) {
value := uint(100)
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr modification safety", func(t *testing.T) {
value := 10
ptr := ToPtr(value)
*ptr = 20
assert.Equal(t, 10, value, "Original value should not be modified")
assert.Equal(t, 20, *ptr, "Pointer value should be modified")
})
t.Run("ToPtr with byte array", func(t *testing.T) {
value := [3]byte{1, 2, 3}
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
})
t.Run("ToPtr with rune", func(t *testing.T) {
value := 'A'
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Equal(t, int32(65), *ptr)
})
t.Run("ToPtr with empty string", func(t *testing.T) {
value := ""
ptr := ToPtr(value)
require.NotNil(t, ptr)
assert.Equal(t, value, *ptr)
assert.Empty(t, *ptr)
})
t.Run("ToPtr with zero values", func(t *testing.T) {
// Test with various zero values
intZero := 0
intPtr := ToPtr(intZero)
require.NotNil(t, intPtr)
assert.Equal(t, 0, *intPtr)
boolZero := false
boolPtr := ToPtr(boolZero)
require.NotNil(t, boolPtr)
assert.False(t, *boolPtr)
floatZero := 0.0
floatPtr := ToPtr(floatZero)
require.NotNil(t, floatPtr)
assert.Equal(t, 0.0, *floatPtr) //nolint:testifylint
})
}

105
pkg/utils/error.go Normal file
View File

@@ -0,0 +1,105 @@
package utils
import (
"errors"
"fmt"
"reflect"
)
// ErrorHandler represents a function that handles a specific error type
type ErrorHandler func(error) error
// ErrorMatcher holds the error type/value and its handler
type ErrorMatcher struct {
ErrorType any // Can be error value (sentinel) or error type
Handler ErrorHandler
IsSentinel bool // true for sentinel errors, false for custom types
}
// HandleError processes an error against a list of matchers and executes the appropriate handler.
// It returns (true, handlerResult) if a matching handler is found and executed,
// or (false, nil) if no matcher matches the error.
// If err is nil, returns (true, nil).
//
// Example:
//
// handled, result := HandleError(err,
// OnSentinelError(io.EOF, func(e error) error {
// return nil // EOF is expected, ignore it
// }),
// OnCustomError(func(e *CustomError) error {
// return fmt.Errorf("custom error: %w", e)
// }),
// )
func HandleError(err error, matchers ...ErrorMatcher) (bool, error) {
if err == nil {
return true, nil
}
for _, matcher := range matchers {
if matcher.IsSentinel {
// Handle sentinel errors with errors.Is
if sentinelErr, ok := matcher.ErrorType.(error); ok {
if errors.Is(err, sentinelErr) {
return true, matcher.Handler(err)
}
}
} else {
// Handle custom error types with errors.As
errorType := reflect.TypeOf(matcher.ErrorType)
errorValue := reflect.New(errorType).Interface()
if errors.As(err, errorValue) {
return true, matcher.Handler(err)
}
}
}
return false, nil // No matcher found
}
// HandleErrorOrDie processes an error against a list of matchers and executes the appropriate handler.
// If a matching handler is found, it returns the handler's result.
// If no matcher matches the error, it panics with a descriptive message.
// This function is useful when all expected error types must be handled explicitly.
//
// Example:
//
// result := HandleErrorOrDie(err,
// OnSentinelError(context.Canceled, func(e error) error {
// return fmt.Errorf("operation canceled")
// }),
// OnCustomError(func(e *ValidationError) error {
// return fmt.Errorf("validation failed: %w", e)
// }),
// ) // Panics if err doesn't match any handler
func HandleErrorOrDie(err error, matchers ...ErrorMatcher) error {
ok, err := HandleError(err, matchers...)
if !ok {
panic(fmt.Sprintf("Unhandled error of type %T: %v", err, err))
}
return err
}
func OnSentinelError(sentinelErr error, handler ErrorHandler) ErrorMatcher {
return ErrorMatcher{
ErrorType: sentinelErr,
Handler: handler,
IsSentinel: true,
}
}
func OnCustomError[T error](handler func(T) error) ErrorMatcher {
var zero T
return ErrorMatcher{
ErrorType: zero,
Handler: func(err error) error {
var typedErr T
if errors.As(err, &typedErr) {
return handler(typedErr)
}
return nil
},
IsSentinel: false,
}
}

386
pkg/utils/error_test.go Normal file
View File

@@ -0,0 +1,386 @@
package utils
import (
"context"
"errors"
"fmt"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Custom error types for testing
type CustomError struct {
Code int
Message string
}
func (e *CustomError) Error() string {
return fmt.Sprintf("custom error %d: %s", e.Code, e.Message)
}
type ValidationError struct {
Field string
Value string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for field %s with value %s", e.Field, e.Value)
}
// Sentinel errors for testing
var (
ErrSentinel1 = errors.New("sentinel error 1")
ErrSentinel2 = errors.New("sentinel error 2")
)
func TestHandleError(t *testing.T) {
t.Run("HandleError with nil error", func(t *testing.T) {
handled, result := HandleError(nil)
assert.True(t, handled)
assert.NoError(t, result)
})
t.Run("HandleError with sentinel error match", func(t *testing.T) {
err := io.EOF
handled, result := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("handled EOF")
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled EOF")
})
t.Run("HandleError with wrapped sentinel error", func(t *testing.T) {
wrappedErr := fmt.Errorf("wrapped: %w", io.EOF)
handled, result := HandleError(wrappedErr,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("handled wrapped EOF")
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled wrapped EOF")
})
t.Run("HandleError with custom error type match", func(t *testing.T) {
err := &CustomError{Code: 404, Message: "not found"}
handled, result := HandleError(err,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("handled custom error with code %d", e.Code)
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled custom error with code 404")
})
t.Run("HandleError with wrapped custom error", func(t *testing.T) {
customErr := &CustomError{Code: 500, Message: "internal error"}
wrappedErr := fmt.Errorf("wrapped: %w", customErr)
handled, result := HandleError(wrappedErr,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("handled wrapped custom error: %s", e.Message)
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "handled wrapped custom error: internal error")
})
t.Run("HandleError with no matching handler", func(t *testing.T) {
err := errors.New("unhandled error")
handled, result := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return nil
}),
OnCustomError(func(e *CustomError) error {
return nil
}),
)
assert.False(t, handled)
assert.NoError(t, result)
})
t.Run("HandleError with multiple matchers first match wins", func(t *testing.T) {
err := io.EOF
handled, result := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("first handler")
}),
OnSentinelError(io.EOF, func(e error) error {
return errors.New("second handler")
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "first handler")
})
t.Run("HandleError with handler returning nil", func(t *testing.T) {
err := io.EOF
handled, result := HandleError(err,
OnSentinelError(io.EOF, func(e error) error {
return nil
}),
)
assert.True(t, handled)
assert.NoError(t, result)
})
t.Run("HandleError with multiple error types", func(t *testing.T) {
customErr := &CustomError{Code: 400, Message: "bad request"}
validationErr := &ValidationError{Field: "email", Value: "invalid"}
// Test CustomError handling
handled1, result1 := HandleError(customErr,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("custom: %d", e.Code)
}),
OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation: %s", e.Field)
}),
)
assert.True(t, handled1)
require.EqualError(t, result1, "custom: 400")
// Test ValidationError handling
handled2, result2 := HandleError(validationErr,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("custom: %d", e.Code)
}),
OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation: %s", e.Field)
}),
)
assert.True(t, handled2)
assert.EqualError(t, result2, "validation: email")
})
t.Run("HandleError with context errors", func(t *testing.T) {
// Test context.Canceled
handled1, result1 := HandleError(context.Canceled,
OnSentinelError(context.Canceled, func(e error) error {
return errors.New("operation canceled")
}),
)
assert.True(t, handled1)
require.EqualError(t, result1, "operation canceled")
// Test context.DeadlineExceeded
handled2, result2 := HandleError(context.DeadlineExceeded,
OnSentinelError(context.DeadlineExceeded, func(e error) error {
return errors.New("deadline exceeded")
}),
)
assert.True(t, handled2)
assert.EqualError(t, result2, "deadline exceeded")
})
t.Run("HandleError preserves original error in handler", func(t *testing.T) {
originalErr := &CustomError{Code: 403, Message: "forbidden"}
var capturedErr error
handled, _ := HandleError(originalErr,
OnCustomError(func(e *CustomError) error {
capturedErr = e
return nil
}),
)
assert.True(t, handled)
assert.Equal(t, originalErr, capturedErr)
})
}
func TestHandleErrorOrDie(t *testing.T) {
t.Run("HandleErrorOrDie with nil error", func(t *testing.T) {
result := HandleErrorOrDie(nil)
assert.NoError(t, result)
})
t.Run("HandleErrorOrDie with matched error", func(t *testing.T) {
err := io.EOF
result := HandleErrorOrDie(err,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("handled EOF in die")
}),
)
assert.EqualError(t, result, "handled EOF in die")
})
t.Run("HandleErrorOrDie panics on unmatched error", func(t *testing.T) {
err := errors.New("unmatched error")
assert.Panics(t, func() {
HandleErrorOrDie(err,
OnSentinelError(io.EOF, func(e error) error {
return nil
}),
)
})
})
t.Run("HandleErrorOrDie with custom error panic", func(t *testing.T) {
customErr := &CustomError{Code: 500, Message: "server error"}
assert.Panics(t, func() {
HandleErrorOrDie(customErr,
OnCustomError(func(e *ValidationError) error {
return nil
}),
)
})
})
t.Run("HandleErrorOrDie with multiple matchers", func(t *testing.T) {
validationErr := &ValidationError{Field: "username", Value: ""}
result := HandleErrorOrDie(validationErr,
OnSentinelError(io.EOF, func(e error) error {
return errors.New("EOF handler")
}),
OnCustomError(func(e *CustomError) error {
return errors.New("custom handler")
}),
OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation handler: field=%s", e.Field)
}),
)
assert.EqualError(t, result, "validation handler: field=username")
})
}
func TestOnSentinelError(t *testing.T) {
t.Run("OnSentinelError creates proper matcher", func(t *testing.T) {
handler := func(e error) error { return e }
matcher := OnSentinelError(io.EOF, handler)
assert.Equal(t, io.EOF, matcher.ErrorType)
assert.True(t, matcher.IsSentinel)
assert.NotNil(t, matcher.Handler)
})
t.Run("OnSentinelError with custom sentinel", func(t *testing.T) {
customSentinel := errors.New("custom sentinel")
callCount := 0
matcher := OnSentinelError(customSentinel, func(e error) error {
callCount++
return errors.New("handled custom sentinel")
})
// Test that it matches the sentinel
handled, result := HandleError(customSentinel, matcher)
assert.True(t, handled)
require.EqualError(t, result, "handled custom sentinel")
assert.Equal(t, 1, callCount)
// Test that it matches wrapped sentinel
wrappedErr := fmt.Errorf("wrapped: %w", customSentinel)
handled, result = HandleError(wrappedErr, matcher)
assert.True(t, handled)
require.EqualError(t, result, "handled custom sentinel")
assert.Equal(t, 2, callCount)
})
}
func TestOnCustomError(t *testing.T) {
t.Run("OnCustomError creates proper matcher", func(t *testing.T) {
matcher := OnCustomError(func(e *CustomError) error {
return fmt.Errorf("handled: %d", e.Code)
})
assert.False(t, matcher.IsSentinel)
assert.NotNil(t, matcher.Handler)
// Test the handler works
err := &CustomError{Code: 200, Message: "ok"}
result := matcher.Handler(err)
assert.EqualError(t, result, "handled: 200")
})
t.Run("OnCustomError with different error types", func(t *testing.T) {
// Create matchers for different types
customMatcher := OnCustomError(func(e *CustomError) error {
return fmt.Errorf("custom error: code=%d", e.Code)
})
validationMatcher := OnCustomError(func(e *ValidationError) error {
return fmt.Errorf("validation error: field=%s", e.Field)
})
// Test with CustomError
customErr := &CustomError{Code: 404, Message: "not found"}
handled, result := HandleError(customErr, customMatcher, validationMatcher)
assert.True(t, handled)
require.EqualError(t, result, "custom error: code=404")
// Test with ValidationError
validationErr := &ValidationError{Field: "age", Value: "-1"}
handled, result = HandleError(validationErr, customMatcher, validationMatcher)
assert.True(t, handled)
assert.EqualError(t, result, "validation error: field=age")
})
t.Run("OnCustomError handler receives correct type", func(t *testing.T) {
var receivedErr *CustomError
matcher := OnCustomError(func(e *CustomError) error {
receivedErr = e
return nil
})
originalErr := &CustomError{Code: 301, Message: "redirect"}
handled, _ := HandleError(originalErr, matcher)
assert.True(t, handled)
require.NotNil(t, receivedErr)
assert.Equal(t, 301, receivedErr.Code)
assert.Equal(t, "redirect", receivedErr.Message)
})
}
func TestErrorMatcherEdgeCases(t *testing.T) {
t.Run("Invalid sentinel error type in matcher", func(t *testing.T) {
// Create a matcher with invalid ErrorType for sentinel
matcher := ErrorMatcher{
ErrorType: "not an error", // Invalid type
Handler: func(e error) error { return e },
IsSentinel: true,
}
err := errors.New("test error")
handled, result := HandleError(err, matcher)
assert.False(t, handled)
assert.NoError(t, result)
})
t.Run("Handler that panics", func(t *testing.T) {
matcher := OnSentinelError(io.EOF, func(e error) error {
panic("handler panic")
})
assert.Panics(t, func() {
HandleError(io.EOF, matcher)
})
})
t.Run("Complex error chain", func(t *testing.T) {
// Create a complex error chain
baseErr := &CustomError{Code: 500, Message: "base"}
wrapped1 := fmt.Errorf("layer1: %w", baseErr)
wrapped2 := fmt.Errorf("layer2: %w", wrapped1)
wrapped3 := fmt.Errorf("layer3: %w", wrapped2)
handled, result := HandleError(wrapped3,
OnCustomError(func(e *CustomError) error {
return fmt.Errorf("found custom error at code %d", e.Code)
}),
)
assert.True(t, handled)
assert.EqualError(t, result, "found custom error at code 500")
})
}

17
pkg/utils/print.go Normal file
View File

@@ -0,0 +1,17 @@
package utils
import (
"fmt"
"os"
"github.com/jedib0t/go-pretty/v6/text"
)
func PrintErr(color text.Color, format string, a ...any) {
fmt.Fprintln(os.Stderr, color.Sprintf(format, a...))
}
func PrintErrAndExit(color text.Color, exitCode int, format string, a ...any) {
PrintErr(color, format, a...)
os.Exit(exitCode)
}

250
pkg/utils/print_test.go Normal file
View File

@@ -0,0 +1,250 @@
package utils
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"strings"
"testing"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrintErr(t *testing.T) {
t.Run("PrintErr writes to stderr with color", func(t *testing.T) {
// Capture stderr
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
// Call PrintErr
PrintErr(text.FgRed, "Error: %s", "test error")
// Restore stderr and read output
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
// The output should contain the message (color codes are included)
assert.Contains(t, output, "test error")
assert.Contains(t, output, "Error:")
assert.True(t, strings.HasSuffix(output, "\n"))
})
t.Run("PrintErr with multiple format arguments", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgYellow, "Warning: %s at line %d", "issue", 42)
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Warning: issue at line 42")
})
t.Run("PrintErr with no format arguments", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgGreen, "Simple message")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Simple message")
assert.True(t, strings.HasSuffix(output, "\n"))
})
t.Run("PrintErr with different colors", func(t *testing.T) {
colors := []text.Color{
text.FgRed,
text.FgGreen,
text.FgYellow,
text.FgBlue,
text.FgMagenta,
text.FgCyan,
}
for _, color := range colors {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(color, "Message with color")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Message with color")
}
})
t.Run("PrintErr with empty string", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgRed, "")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Equal(t, "\n", strings.TrimPrefix(output, "\x1b[31m\x1b[0m")) // Just newline after color codes
})
t.Run("PrintErr with special characters", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgRed, "Special chars: %s", "!@#$%^&*()")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Special chars: !@#$%^&*()")
})
t.Run("PrintErr with percent sign in message", func(t *testing.T) {
oldStderr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stderr = writer
PrintErr(text.FgRed, "Progress: 100%% complete")
writer.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, reader)
output := buf.String()
assert.Contains(t, output, "Progress: 100% complete")
})
}
func TestPrintErrAndExit(t *testing.T) {
if os.Getenv("BE_CRASHER") == "1" {
// This is the subprocess that will actually call PrintErrAndExit
exitCode := 1
if code := os.Getenv("EXIT_CODE"); code != "" {
switch code {
case "0":
exitCode = 0
case "1":
exitCode = 1
case "2":
exitCode = 2
}
}
PrintErrAndExit(text.FgRed, exitCode, "Error: %s", "fatal error")
return
}
t.Run("PrintErrAndExit calls os.Exit with correct code", func(t *testing.T) {
testCases := []struct {
name string
exitCode int
}{
{"Exit with code 0", 0},
{"Exit with code 1", 1},
{"Exit with code 2", 2},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ctx := context.Background()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
cmd.Env = append(os.Environ(),
"BE_CRASHER=1",
"EXIT_CODE="+string(rune('0'+testCase.exitCode)))
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
if testCase.exitCode == 0 {
require.NoError(t, err)
} else {
require.Error(t, err)
if exitErr, ok := err.(*exec.ExitError); ok {
assert.Equal(t, testCase.exitCode, exitErr.ExitCode())
}
}
// Check that error message was printed to stderr
assert.Contains(t, stderr.String(), "Error: fatal error")
})
}
})
t.Run("PrintErrAndExit prints before exiting", func(t *testing.T) {
ctx := context.Background()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
cmd.Env = append(os.Environ(), "BE_CRASHER=1", "EXIT_CODE=1")
var stderr bytes.Buffer
cmd.Stderr = &stderr
cmd.Run() // Ignore error since we expect non-zero exit
output := stderr.String()
assert.Contains(t, output, "Error: fatal error")
assert.True(t, strings.HasSuffix(output, "\n"))
})
}
// Benchmarks for performance testing
func BenchmarkPrintErr(b *testing.B) {
// Redirect stderr to /dev/null for benchmarking
oldStderr := os.Stderr
devNull, _ := os.Open(os.DevNull)
os.Stderr = devNull
defer func() {
os.Stderr = oldStderr
devNull.Close()
}()
b.Run("Simple message", func(b *testing.B) {
for range b.N {
PrintErr(text.FgRed, "Error message")
}
})
b.Run("Formatted message", func(b *testing.B) {
for range b.N {
PrintErr(text.FgRed, "Error: %s at line %d", "issue", 42)
}
})
b.Run("Different colors", func(b *testing.B) {
colors := []text.Color{text.FgRed, text.FgGreen, text.FgYellow}
for idx := range b.N {
PrintErr(colors[idx%len(colors)], "Message %d", idx)
}
})
}