mirror of
https://github.com/ivuorinen/gibidify.git
synced 2026-01-26 03:24:05 +00:00
* build: update Go 1.25, CI workflows, and build tooling - Upgrade to Go 1.25 - Add benchmark targets to Makefile - Implement parallel gosec execution - Lock tool versions for reproducibility - Add shellcheck directives to scripts - Update CI workflows with improved caching * refactor: migrate from golangci-lint to revive - Replace golangci-lint with revive for linting - Configure comprehensive revive rules - Fix all EditorConfig violations - Add yamllint and yamlfmt support - Remove deprecated .golangci.yml * refactor: rename utils to shared and deduplicate code - Rename utils package to shared - Add shared constants package - Deduplicate constants across packages - Address CodeRabbit review feedback * fix: resolve SonarQube issues and add safety guards - Fix all 73 SonarQube OPEN issues - Add nil guards for resourceMonitor, backpressure, metricsCollector - Implement io.Closer for headerFileReader - Propagate errors from processing helpers - Add metrics and templates packages - Improve error handling across codebase * test: improve test infrastructure and coverage - Add benchmarks for cli, fileproc, metrics - Improve test coverage for cli, fileproc, config - Refactor tests with helper functions - Add shared test constants - Fix test function naming conventions - Reduce cognitive complexity in benchmark tests * docs: update documentation and configuration examples - Update CLAUDE.md with current project state - Refresh README with new features - Add usage and configuration examples - Add SonarQube project configuration - Consolidate config.example.yaml * fix: resolve shellcheck warnings in scripts - Use ./*.go instead of *.go to prevent dash-prefixed filenames from being interpreted as options (SC2035) - Remove unreachable return statement after exit (SC2317) - Remove obsolete gibidiutils/ directory reference * chore(deps): upgrade go dependencies * chore(lint): megalinter fixes * fix: improve test coverage and fix file descriptor leaks - Add defer r.Close() to fix pipe file descriptor leaks in benchmark tests - Refactor TestProcessorConfigureFileTypes with helper functions and assertions - Refactor TestProcessorLogFinalStats with output capture and keyword verification - Use shared constants instead of literal strings (TestFilePNG, FormatMarkdown, etc.) - Reduce cognitive complexity by extracting helper functions * fix: align test comments with function names Remove underscores from test comments to match actual function names: - benchmark/benchmark_test.go (2 fixes) - fileproc/filetypes_config_test.go (4 fixes) - fileproc/filetypes_registry_test.go (6 fixes) - fileproc/processor_test.go (6 fixes) - fileproc/resource_monitor_types_test.go (4 fixes) - fileproc/writer_test.go (3 fixes) * fix: various test improvements and bug fixes - Remove duplicate maxCacheSize check in filetypes_registry_test.go - Shorten long comment in processor_test.go to stay under 120 chars - Remove flaky time.Sleep in collector_test.go, use >= 0 assertion - Close pipe reader in benchmark_test.go to fix file descriptor leak - Use ContinueOnError in flags_test.go to match ResetFlags behavior - Add nil check for p.ui in processor_workers.go before UpdateProgress - Fix resource_monitor_validation_test.go by setting hardMemoryLimitBytes directly * chore(yaml): add missing document start markers Add --- document start to YAML files to satisfy yamllint: - .github/workflows/codeql.yml - .github/workflows/build-test-publish.yml - .github/workflows/security.yml - .github/actions/setup/action.yml * fix: guard nil resourceMonitor and fix test deadlock - Guard resourceMonitor before CreateFileProcessingContext call - Add ui.UpdateProgress on emergency stop and path error returns - Fix potential deadlock in TestProcessFile using wg.Go with defer close
933 lines
22 KiB
Go
933 lines
22 KiB
Go
package shared
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// captureLogOutput captures logger output for testing.
|
|
func captureLogOutput(f func()) string {
|
|
var buf bytes.Buffer
|
|
logger := GetLogger()
|
|
logger.SetOutput(&buf)
|
|
defer logger.SetOutput(io.Discard) // Set to discard to avoid test output noise
|
|
f()
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func TestLogError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
operation string
|
|
err error
|
|
args []any
|
|
wantLog string
|
|
wantEmpty bool
|
|
}{
|
|
{
|
|
name: "nil error should not log",
|
|
operation: "test operation",
|
|
err: nil,
|
|
args: nil,
|
|
wantEmpty: true,
|
|
},
|
|
{
|
|
name: "basic error logging",
|
|
operation: "failed to read file",
|
|
err: errors.New("permission denied"),
|
|
args: nil,
|
|
wantLog: "failed to read file: permission denied",
|
|
},
|
|
{
|
|
name: "error with formatting args",
|
|
operation: "failed to process file %s",
|
|
err: errors.New("file too large"),
|
|
args: []any{"test.txt"},
|
|
wantLog: "failed to process file test.txt: file too large",
|
|
},
|
|
{
|
|
name: "error with multiple formatting args",
|
|
operation: "failed to copy from %s to %s",
|
|
err: errors.New(TestErrDiskFull),
|
|
args: []any{"source.txt", "dest.txt"},
|
|
wantLog: "failed to copy from source.txt to dest.txt: disk full",
|
|
},
|
|
{
|
|
name: "wrapped error",
|
|
operation: "database operation failed",
|
|
err: fmt.Errorf("connection error: %w", errors.New("timeout")),
|
|
args: nil,
|
|
wantLog: "database operation failed: connection error: timeout",
|
|
},
|
|
{
|
|
name: "empty operation string",
|
|
operation: "",
|
|
err: errors.New("some error"),
|
|
args: nil,
|
|
wantLog: ": some error",
|
|
},
|
|
{
|
|
name: "operation with percentage sign",
|
|
operation: "processing 50% complete",
|
|
err: errors.New("interrupted"),
|
|
args: nil,
|
|
wantLog: "processing 50% complete: interrupted",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
output := captureLogOutput(
|
|
func() {
|
|
LogError(tt.operation, tt.err, tt.args...)
|
|
},
|
|
)
|
|
|
|
if tt.wantEmpty {
|
|
if output != "" {
|
|
t.Errorf("LogError() logged output when error was nil: %q", output)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(output, tt.wantLog) {
|
|
t.Errorf("LogError() output = %q, want to contain %q", output, tt.wantLog)
|
|
}
|
|
|
|
// Verify it's logged at ERROR level
|
|
if !strings.Contains(output, "level=error") {
|
|
t.Errorf("LogError() should log at ERROR level, got: %q", output)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLogErrorf(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
format string
|
|
args []any
|
|
wantLog string
|
|
wantEmpty bool
|
|
}{
|
|
{
|
|
name: "nil error should not log",
|
|
err: nil,
|
|
format: "operation %s failed",
|
|
args: []any{"test"},
|
|
wantEmpty: true,
|
|
},
|
|
{
|
|
name: "basic formatted error",
|
|
err: errors.New("not found"),
|
|
format: "file %s not found",
|
|
args: []any{"config.yaml"},
|
|
wantLog: "file config.yaml not found: not found",
|
|
},
|
|
{
|
|
name: "multiple format arguments",
|
|
err: errors.New("invalid range"),
|
|
format: "value %d is not between %d and %d",
|
|
args: []any{150, 0, 100},
|
|
wantLog: "value 150 is not between 0 and 100: invalid range",
|
|
},
|
|
{
|
|
name: "no format arguments",
|
|
err: errors.New("generic error"),
|
|
format: "operation failed",
|
|
args: nil,
|
|
wantLog: "operation failed: generic error",
|
|
},
|
|
{
|
|
name: "format with different types",
|
|
err: errors.New("type mismatch"),
|
|
format: "expected %s but got %d",
|
|
args: []any{"string", 42},
|
|
wantLog: "expected string but got 42: type mismatch",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
output := captureLogOutput(
|
|
func() {
|
|
LogErrorf(tt.err, tt.format, tt.args...)
|
|
},
|
|
)
|
|
|
|
if tt.wantEmpty {
|
|
if output != "" {
|
|
t.Errorf("LogErrorf() logged output when error was nil: %q", output)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if !strings.Contains(output, tt.wantLog) {
|
|
t.Errorf("LogErrorf() output = %q, want to contain %q", output, tt.wantLog)
|
|
}
|
|
|
|
// Verify it's logged at ERROR level
|
|
if !strings.Contains(output, "level=error") {
|
|
t.Errorf("LogErrorf() should log at ERROR level, got: %q", output)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLogErrorConcurrency(_ *testing.T) {
|
|
// Test that LogError is safe for concurrent use
|
|
done := make(chan bool)
|
|
for i := range 10 {
|
|
go func(n int) {
|
|
LogError("concurrent operation", fmt.Errorf("error %d", n))
|
|
done <- true
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines to complete
|
|
for range 10 {
|
|
<-done
|
|
}
|
|
}
|
|
|
|
func TestLogErrorfConcurrency(_ *testing.T) {
|
|
// Test that LogErrorf is safe for concurrent use
|
|
done := make(chan bool)
|
|
for i := range 10 {
|
|
go func(n int) {
|
|
LogErrorf(fmt.Errorf("error %d", n), "concurrent operation %d", n)
|
|
done <- true
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines to complete
|
|
for range 10 {
|
|
<-done
|
|
}
|
|
}
|
|
|
|
// BenchmarkLogError benchmarks the LogError function.
|
|
func BenchmarkLogError(b *testing.B) {
|
|
err := errors.New("benchmark error")
|
|
// Disable output during benchmark
|
|
logger := GetLogger()
|
|
logger.SetOutput(io.Discard)
|
|
defer logger.SetOutput(io.Discard)
|
|
|
|
for b.Loop() {
|
|
LogError("benchmark operation", err)
|
|
}
|
|
}
|
|
|
|
// BenchmarkLogErrorf benchmarks the LogErrorf function.
|
|
func BenchmarkLogErrorf(b *testing.B) {
|
|
err := errors.New("benchmark error")
|
|
// Disable output during benchmark
|
|
logger := GetLogger()
|
|
logger.SetOutput(io.Discard)
|
|
defer logger.SetOutput(io.Discard)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
LogErrorf(err, "benchmark operation %d", i)
|
|
}
|
|
}
|
|
|
|
// BenchmarkLogErrorNil benchmarks LogError with nil error (no-op case).
|
|
func BenchmarkLogErrorNil(b *testing.B) {
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
LogError("benchmark operation", nil)
|
|
}
|
|
}
|
|
|
|
func TestErrorTypeString(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
errType ErrorType
|
|
expected string
|
|
}{
|
|
{
|
|
name: "CLI error type",
|
|
errType: ErrorTypeCLI,
|
|
expected: "CLI",
|
|
},
|
|
{
|
|
name: "FileSystem error type",
|
|
errType: ErrorTypeFileSystem,
|
|
expected: "FileSystem",
|
|
},
|
|
{
|
|
name: "Processing error type",
|
|
errType: ErrorTypeProcessing,
|
|
expected: "Processing",
|
|
},
|
|
{
|
|
name: "Configuration error type",
|
|
errType: ErrorTypeConfiguration,
|
|
expected: "Configuration",
|
|
},
|
|
{
|
|
name: "IO error type",
|
|
errType: ErrorTypeIO,
|
|
expected: "IO",
|
|
},
|
|
{
|
|
name: "Validation error type",
|
|
errType: ErrorTypeValidation,
|
|
expected: "Validation",
|
|
},
|
|
{
|
|
name: "Unknown error type",
|
|
errType: ErrorTypeUnknown,
|
|
expected: "Unknown",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
result := tt.errType.String()
|
|
if result != tt.expected {
|
|
t.Errorf("ErrorType.String() = %q, want %q", result, tt.expected)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestStructuredErrorError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err *StructuredError
|
|
expected string
|
|
}{
|
|
{
|
|
name: "error without cause",
|
|
err: &StructuredError{
|
|
Type: ErrorTypeFileSystem,
|
|
Code: "ACCESS_DENIED",
|
|
Message: "permission denied",
|
|
},
|
|
expected: "FileSystem [ACCESS_DENIED]: permission denied",
|
|
},
|
|
{
|
|
name: "error with cause",
|
|
err: &StructuredError{
|
|
Type: ErrorTypeIO,
|
|
Code: "WRITE_FAILED",
|
|
Message: "unable to write file",
|
|
Cause: errors.New(TestErrDiskFull),
|
|
},
|
|
expected: "IO [WRITE_FAILED]: unable to write file: disk full",
|
|
},
|
|
{
|
|
name: "error with empty message",
|
|
err: &StructuredError{
|
|
Type: ErrorTypeValidation,
|
|
Code: "INVALID_FORMAT",
|
|
Message: "",
|
|
},
|
|
expected: "Validation [INVALID_FORMAT]: ",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
result := tt.err.Error()
|
|
if result != tt.expected {
|
|
t.Errorf("StructuredError.Error() = %q, want %q", result, tt.expected)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestStructuredErrorUnwrap(t *testing.T) {
|
|
originalErr := errors.New("original error")
|
|
|
|
tests := []struct {
|
|
name string
|
|
err *StructuredError
|
|
expected error
|
|
}{
|
|
{
|
|
name: "error with cause",
|
|
err: &StructuredError{
|
|
Type: ErrorTypeIO,
|
|
Code: "READ_FAILED",
|
|
Cause: originalErr,
|
|
},
|
|
expected: originalErr,
|
|
},
|
|
{
|
|
name: "error without cause",
|
|
err: &StructuredError{
|
|
Type: ErrorTypeValidation,
|
|
Code: "INVALID_INPUT",
|
|
},
|
|
expected: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
result := tt.err.Unwrap()
|
|
if !errors.Is(result, tt.expected) {
|
|
t.Errorf("StructuredError.Unwrap() = %v, want %v", result, tt.expected)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestStructuredErrorWithContext(t *testing.T) {
|
|
err := &StructuredError{
|
|
Type: ErrorTypeProcessing,
|
|
Code: "PROCESSING_FAILED",
|
|
Message: "processing error",
|
|
}
|
|
|
|
// Test adding context to error without existing context
|
|
result := err.WithContext("key1", "value1")
|
|
|
|
// Should return the same error instance
|
|
if !errors.Is(result, err) {
|
|
t.Error("WithContext() should return the same error instance")
|
|
}
|
|
|
|
// Check that context was added
|
|
if len(err.Context) != 1 {
|
|
t.Errorf("Expected context length 1, got %d", len(err.Context))
|
|
}
|
|
|
|
if err.Context["key1"] != "value1" {
|
|
t.Errorf("Expected context key1=value1, got %v", err.Context["key1"])
|
|
}
|
|
|
|
// Test adding more context
|
|
err = err.WithContext("key2", 42)
|
|
|
|
if len(err.Context) != 2 {
|
|
t.Errorf("Expected context length 2, got %d", len(err.Context))
|
|
}
|
|
|
|
if err.Context["key2"] != 42 {
|
|
t.Errorf("Expected context key2=42, got %v", err.Context["key2"])
|
|
}
|
|
}
|
|
|
|
func TestStructuredErrorWithFilePath(t *testing.T) {
|
|
err := &StructuredError{
|
|
Type: ErrorTypeFileSystem,
|
|
Code: "FILE_NOT_FOUND",
|
|
Message: "file not found",
|
|
}
|
|
|
|
filePath := "/path/to/file.txt"
|
|
result := err.WithFilePath(filePath)
|
|
|
|
// Should return the same error instance
|
|
if !errors.Is(result, err) {
|
|
t.Error("WithFilePath() should return the same error instance")
|
|
}
|
|
|
|
// Check that file path was set
|
|
if err.FilePath != filePath {
|
|
t.Errorf(TestFmtExpectedFilePath, filePath, err.FilePath)
|
|
}
|
|
|
|
// Test overwriting existing file path
|
|
newPath := "/another/path.txt"
|
|
err = err.WithFilePath(newPath)
|
|
|
|
if err.FilePath != newPath {
|
|
t.Errorf(TestFmtExpectedFilePath, newPath, err.FilePath)
|
|
}
|
|
}
|
|
|
|
func TestStructuredErrorWithLine(t *testing.T) {
|
|
err := &StructuredError{
|
|
Type: ErrorTypeValidation,
|
|
Code: "SYNTAX_ERROR",
|
|
Message: "syntax error",
|
|
}
|
|
|
|
lineNum := 42
|
|
result := err.WithLine(lineNum)
|
|
|
|
// Should return the same error instance
|
|
if !errors.Is(result, err) {
|
|
t.Error("WithLine() should return the same error instance")
|
|
}
|
|
|
|
// Check that line number was set
|
|
if err.Line != lineNum {
|
|
t.Errorf(TestFmtExpectedLine, lineNum, err.Line)
|
|
}
|
|
|
|
// Test overwriting existing line number
|
|
newLine := 100
|
|
err = err.WithLine(newLine)
|
|
|
|
if err.Line != newLine {
|
|
t.Errorf(TestFmtExpectedLine, newLine, err.Line)
|
|
}
|
|
}
|
|
|
|
// validateStructuredErrorBasics validates basic structured error fields.
|
|
func validateStructuredErrorBasics(
|
|
t *testing.T,
|
|
err *StructuredError,
|
|
errorType ErrorType,
|
|
code, message, filePath string,
|
|
) {
|
|
t.Helper()
|
|
|
|
if err.Type != errorType {
|
|
t.Errorf(TestFmtExpectedType, errorType, err.Type)
|
|
}
|
|
if err.Code != code {
|
|
t.Errorf(TestFmtExpectedCode, code, err.Code)
|
|
}
|
|
if err.Message != message {
|
|
t.Errorf(TestFmtExpectedMessage, message, err.Message)
|
|
}
|
|
if err.FilePath != filePath {
|
|
t.Errorf(TestFmtExpectedFilePath, filePath, err.FilePath)
|
|
}
|
|
}
|
|
|
|
// validateStructuredErrorContext validates context fields.
|
|
func validateStructuredErrorContext(t *testing.T, err *StructuredError, expectedContext map[string]any) {
|
|
t.Helper()
|
|
|
|
if expectedContext == nil {
|
|
if len(err.Context) != 0 {
|
|
t.Errorf("Expected empty context, got %v", err.Context)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if len(err.Context) != len(expectedContext) {
|
|
t.Errorf("Expected context length %d, got %d", len(expectedContext), len(err.Context))
|
|
}
|
|
|
|
for k, v := range expectedContext {
|
|
if err.Context[k] != v {
|
|
t.Errorf("Expected context[%q] = %v, got %v", k, v, err.Context[k])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewStructuredError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
errorType ErrorType
|
|
code string
|
|
message string
|
|
filePath string
|
|
context map[string]any
|
|
}{
|
|
{
|
|
name: "basic structured error",
|
|
errorType: ErrorTypeFileSystem,
|
|
code: "ACCESS_DENIED",
|
|
message: TestErrAccessDenied,
|
|
filePath: "/test/file.txt",
|
|
context: nil,
|
|
},
|
|
{
|
|
name: "error with context",
|
|
errorType: ErrorTypeValidation,
|
|
code: "INVALID_FORMAT",
|
|
message: "invalid format",
|
|
filePath: "",
|
|
context: map[string]any{
|
|
"expected": "json",
|
|
"got": "xml",
|
|
},
|
|
},
|
|
{
|
|
name: "error with all fields",
|
|
errorType: ErrorTypeIO,
|
|
code: "WRITE_FAILED",
|
|
message: "write failed",
|
|
filePath: "/output/file.txt",
|
|
context: map[string]any{
|
|
"bytes_written": 1024,
|
|
"total_size": 2048,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
err := NewStructuredError(tt.errorType, tt.code, tt.message, tt.filePath, tt.context)
|
|
validateStructuredErrorBasics(t, err, tt.errorType, tt.code, tt.message, tt.filePath)
|
|
validateStructuredErrorContext(t, err, tt.context)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNewStructuredErrorf(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
errorType ErrorType
|
|
code string
|
|
format string
|
|
args []any
|
|
expectedMsg string
|
|
}{
|
|
{
|
|
name: "formatted error without args",
|
|
errorType: ErrorTypeProcessing,
|
|
code: "PROCESSING_FAILED",
|
|
format: TestErrProcessingFailed,
|
|
args: nil,
|
|
expectedMsg: TestErrProcessingFailed,
|
|
},
|
|
{
|
|
name: "formatted error with args",
|
|
errorType: ErrorTypeValidation,
|
|
code: "INVALID_VALUE",
|
|
format: "invalid value %q, expected between %d and %d",
|
|
args: []any{"150", 0, 100},
|
|
expectedMsg: "invalid value \"150\", expected between 0 and 100",
|
|
},
|
|
{
|
|
name: "formatted error with multiple types",
|
|
errorType: ErrorTypeIO,
|
|
code: "READ_ERROR",
|
|
format: "failed to read %d bytes from %s",
|
|
args: []any{1024, "/tmp/file.txt"},
|
|
expectedMsg: "failed to read 1024 bytes from /tmp/file.txt",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
err := NewStructuredErrorf(tt.errorType, tt.code, tt.format, tt.args...)
|
|
|
|
if err.Type != tt.errorType {
|
|
t.Errorf(TestFmtExpectedType, tt.errorType, err.Type)
|
|
}
|
|
if err.Code != tt.code {
|
|
t.Errorf(TestFmtExpectedCode, tt.code, err.Code)
|
|
}
|
|
if err.Message != tt.expectedMsg {
|
|
t.Errorf(TestFmtExpectedMessage, tt.expectedMsg, err.Message)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// validateWrapErrorResult validates wrap error results.
|
|
func validateWrapErrorResult(
|
|
t *testing.T,
|
|
result *StructuredError,
|
|
originalErr error,
|
|
errorType ErrorType,
|
|
code, message string,
|
|
) {
|
|
t.Helper()
|
|
|
|
if result.Type != errorType {
|
|
t.Errorf(TestFmtExpectedType, errorType, result.Type)
|
|
}
|
|
if result.Code != code {
|
|
t.Errorf(TestFmtExpectedCode, code, result.Code)
|
|
}
|
|
if result.Message != message {
|
|
t.Errorf(TestFmtExpectedMessage, message, result.Message)
|
|
}
|
|
if !errors.Is(result.Cause, originalErr) {
|
|
t.Errorf("Expected Cause %v, got %v", originalErr, result.Cause)
|
|
}
|
|
|
|
if originalErr != nil && !errors.Is(result, originalErr) {
|
|
t.Error("Expected errors.Is to return true for wrapped error")
|
|
}
|
|
}
|
|
|
|
func TestWrapError(t *testing.T) {
|
|
originalErr := errors.New("original error")
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
errorType ErrorType
|
|
code string
|
|
message string
|
|
}{
|
|
{
|
|
name: "wrap simple error",
|
|
err: originalErr,
|
|
errorType: ErrorTypeFileSystem,
|
|
code: "ACCESS_DENIED",
|
|
message: TestErrAccessDenied,
|
|
},
|
|
{
|
|
name: "wrap nil error",
|
|
err: nil,
|
|
errorType: ErrorTypeValidation,
|
|
code: "INVALID_INPUT",
|
|
message: "invalid input",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
result := WrapError(tt.err, tt.errorType, tt.code, tt.message)
|
|
validateWrapErrorResult(t, result, tt.err, tt.errorType, tt.code, tt.message)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestWrapErrorf(t *testing.T) {
|
|
originalErr := errors.New(TestErrDiskFull)
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
errorType ErrorType
|
|
code string
|
|
format string
|
|
args []any
|
|
expectedMsg string
|
|
}{
|
|
{
|
|
name: "wrap with formatted message",
|
|
err: originalErr,
|
|
errorType: ErrorTypeIO,
|
|
code: "WRITE_FAILED",
|
|
format: "failed to write %d bytes to %s",
|
|
args: []any{1024, "/tmp/output.txt"},
|
|
expectedMsg: "failed to write 1024 bytes to /tmp/output.txt",
|
|
},
|
|
{
|
|
name: "wrap without args",
|
|
err: originalErr,
|
|
errorType: ErrorTypeProcessing,
|
|
code: "PROCESSING_ERROR",
|
|
format: TestErrProcessingFailed,
|
|
args: nil,
|
|
expectedMsg: TestErrProcessingFailed,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
result := WrapErrorf(tt.err, tt.errorType, tt.code, tt.format, tt.args...)
|
|
|
|
if result.Type != tt.errorType {
|
|
t.Errorf(TestFmtExpectedType, tt.errorType, result.Type)
|
|
}
|
|
if result.Code != tt.code {
|
|
t.Errorf(TestFmtExpectedCode, tt.code, result.Code)
|
|
}
|
|
if result.Message != tt.expectedMsg {
|
|
t.Errorf(TestFmtExpectedMessage, tt.expectedMsg, result.Message)
|
|
}
|
|
if !errors.Is(result.Cause, tt.err) {
|
|
t.Errorf("Expected Cause %v, got %v", tt.err, result.Cause)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// validatePredefinedError validates predefined error constructor results.
|
|
func validatePredefinedError(t *testing.T, err *StructuredError, expectedType ErrorType, name, code, message string) {
|
|
t.Helper()
|
|
|
|
if err.Type != expectedType {
|
|
t.Errorf(TestFmtExpectedType, expectedType, err.Type)
|
|
}
|
|
|
|
if name != "NewMissingSourceError" {
|
|
if err.Code != code {
|
|
t.Errorf(TestFmtExpectedCode, code, err.Code)
|
|
}
|
|
if err.Message != message {
|
|
t.Errorf(TestFmtExpectedMessage, message, err.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPredefinedErrorConstructors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
constructor func(string, string) *StructuredError
|
|
expectedType ErrorType
|
|
}{
|
|
{
|
|
name: "NewMissingSourceError",
|
|
constructor: func(_, _ string) *StructuredError { return NewMissingSourceError() },
|
|
expectedType: ErrorTypeCLI,
|
|
},
|
|
{
|
|
name: "NewFileSystemError",
|
|
constructor: NewFileSystemError,
|
|
expectedType: ErrorTypeFileSystem,
|
|
},
|
|
{
|
|
name: "NewProcessingError",
|
|
constructor: NewProcessingError,
|
|
expectedType: ErrorTypeProcessing,
|
|
},
|
|
{
|
|
name: "NewIOError",
|
|
constructor: NewIOError,
|
|
expectedType: ErrorTypeIO,
|
|
},
|
|
{
|
|
name: "NewValidationError",
|
|
constructor: NewValidationError,
|
|
expectedType: ErrorTypeValidation,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name, func(t *testing.T) {
|
|
code := "TEST_CODE"
|
|
message := "test message"
|
|
|
|
var err *StructuredError
|
|
if tt.name == "NewMissingSourceError" {
|
|
err = NewMissingSourceError()
|
|
} else {
|
|
err = tt.constructor(code, message)
|
|
}
|
|
|
|
validatePredefinedError(t, err, tt.expectedType, tt.name, code, message)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestStructuredErrorIntegration(t *testing.T) {
|
|
// Test a complete structured error workflow
|
|
originalErr := errors.New("connection timeout")
|
|
|
|
// Create and modify error through chaining
|
|
err := WrapError(originalErr, ErrorTypeIO, "READ_TIMEOUT", "failed to read from network").
|
|
WithFilePath(TestPathTmpNetworkData).
|
|
WithLine(42).
|
|
WithContext("host", "example.com").
|
|
WithContext("port", 8080)
|
|
|
|
// Test error interface implementation
|
|
errorMsg := err.Error()
|
|
expectedMsg := "IO [READ_TIMEOUT]: failed to read from network: connection timeout"
|
|
if errorMsg != expectedMsg {
|
|
t.Errorf("Expected error message %q, got %q", expectedMsg, errorMsg)
|
|
}
|
|
|
|
// Test unwrapping
|
|
if !errors.Is(err, originalErr) {
|
|
t.Error("Expected errors.Is to return true for wrapped error")
|
|
}
|
|
|
|
// Test properties
|
|
if err.FilePath != TestPathTmpNetworkData {
|
|
t.Errorf(TestFmtExpectedFilePath, TestPathTmpNetworkData, err.FilePath)
|
|
}
|
|
if err.Line != 42 {
|
|
t.Errorf(TestFmtExpectedLine, 42, err.Line)
|
|
}
|
|
if len(err.Context) != 2 {
|
|
t.Errorf("Expected context length 2, got %d", len(err.Context))
|
|
}
|
|
if err.Context["host"] != "example.com" {
|
|
t.Errorf("Expected context host=example.com, got %v", err.Context["host"])
|
|
}
|
|
if err.Context["port"] != 8080 {
|
|
t.Errorf("Expected context port=8080, got %v", err.Context["port"])
|
|
}
|
|
}
|
|
|
|
func TestErrorTypeConstants(t *testing.T) {
|
|
// Test that all error type constants are properly defined
|
|
types := []ErrorType{
|
|
ErrorTypeCLI,
|
|
ErrorTypeFileSystem,
|
|
ErrorTypeProcessing,
|
|
ErrorTypeConfiguration,
|
|
ErrorTypeIO,
|
|
ErrorTypeValidation,
|
|
ErrorTypeUnknown,
|
|
}
|
|
|
|
// Ensure all types have unique string representations
|
|
seen := make(map[string]bool)
|
|
for _, errType := range types {
|
|
str := errType.String()
|
|
if seen[str] {
|
|
t.Errorf("Duplicate string representation: %q", str)
|
|
}
|
|
seen[str] = true
|
|
|
|
if str == "" {
|
|
t.Errorf("Empty string representation for error type %v", errType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Benchmark tests for StructuredError operations.
|
|
func BenchmarkNewStructuredError(b *testing.B) {
|
|
context := map[string]any{
|
|
"key1": "value1",
|
|
"key2": 42,
|
|
}
|
|
|
|
for b.Loop() {
|
|
_ = NewStructuredError( // nolint:errcheck // benchmark test
|
|
ErrorTypeFileSystem,
|
|
"ACCESS_DENIED",
|
|
TestErrAccessDenied,
|
|
"/test/file.txt",
|
|
context,
|
|
)
|
|
}
|
|
}
|
|
|
|
func BenchmarkStructuredErrorError(b *testing.B) {
|
|
err := NewStructuredError(ErrorTypeIO, "WRITE_FAILED", "write operation failed", "/tmp/file.txt", nil)
|
|
|
|
for b.Loop() {
|
|
_ = err.Error()
|
|
}
|
|
}
|
|
|
|
func BenchmarkStructuredErrorWithContext(b *testing.B) {
|
|
err := NewStructuredError(ErrorTypeProcessing, "PROC_FAILED", TestErrProcessingFailed, "", nil)
|
|
|
|
for i := 0; b.Loop(); i++ {
|
|
_ = err.WithContext(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i)) // nolint:errcheck // benchmark test
|
|
}
|
|
}
|