diff --git a/go.mod b/go.mod index bed6d6a..dd6f0e8 100644 --- a/go.mod +++ b/go.mod @@ -10,11 +10,18 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) +toolchain go1.23.7 + require ( + github.com/rs/zerolog v1.33.0 github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.10.0 + github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 + golang.org/x/text v0.23.0 // indirect + golang.org/x/crypto v0.36.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -36,7 +43,11 @@ require ( require golang.org/x/text v0.23.0 // indirect require golang.org/x/sys v0.31.0 // indirect - +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + golang.org/x/sys v0.31.0 // indirect +) require ( cloud.google.com/go/pubsub v1.48.0 gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index d2b2824..7df543a 100644 --- a/go.sum +++ b/go.sum @@ -35,10 +35,24 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+x github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03 h1:m1h+vudopHsI67FPT9MOncyndWhTcdUoBtI1R1uajGY= github.com/zenazn/pkcs7pad v0.0.0-20170308005700-253a5b1f0e03/go.mod h1:8sheVFH84v3PCyFY/O02mIgSQY9I6wMYPWsq7mDnEZY= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= @@ -50,3 +64,5 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..8531eee --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,271 @@ +package log + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/rs/zerolog" + "gopkg.in/natefinch/lumberjack.v2" +) + +type level string + +type destinationType string + +type destination struct { + Type destinationType `yaml:"type"` + Config map[string]string `yaml:"config"` +} + +const ( + Stdout destinationType = "stdout" + File destinationType = "file" +) + +const ( + DebugLevel level = "debug" + InfoLevel level = "info" + WarnLevel level = "warn" + ErrorLevel level = "error" + FatalLevel level = "fatal" + PanicLevel level = "panic" +) + +var logLevels = map[level]zerolog.Level{ + DebugLevel: zerolog.DebugLevel, + InfoLevel: zerolog.InfoLevel, + WarnLevel: zerolog.WarnLevel, + ErrorLevel: zerolog.ErrorLevel, + FatalLevel: zerolog.FatalLevel, + PanicLevel: zerolog.PanicLevel, +} + +type Config struct { + Level level `yaml:"level"` + Destinations []destination `yaml:"destinations"` + ContextKeys []string `yaml:"contextKeys"` +} + +var ( + logger zerolog.Logger + cfg Config + once sync.Once +) + +var ( + ErrInvalidLogLevel = errors.New("invalid log level") + ErrLogDestinationNil = errors.New("log Destinations cant be empty") + ErrMissingFilePath = errors.New("file path missing in destination config for file logging") +) + +func (config *Config) validate() error { + if _, exists := logLevels[config.Level]; !exists { + return ErrInvalidLogLevel + } + + if len(config.Destinations) == 0 { + return ErrLogDestinationNil + } + + for _, dest := range config.Destinations { + switch dest.Type { + case Stdout: + case File: + if _, exists := dest.Config["path"]; !exists { + return ErrMissingFilePath + } + + for _, key := range []string{"maxSize", "maxBackups", "maxAge"} { + if valStr, ok := dest.Config[key]; ok { + if _, err := strconv.Atoi(valStr); err != nil { + return fmt.Errorf("invalid %s: %w", key, err) + } + } + } + default: + return fmt.Errorf("invalid destination type '%s'", dest.Type) + } + } + return nil +} + +var defaultConfig = Config{ + Level: InfoLevel, + Destinations: []destination{ + {Type: Stdout}, + }, +} + +func init() { + logger, _ = getLogger(defaultConfig) +} + +func getLogger(config Config) (zerolog.Logger, error) { + var newLogger zerolog.Logger + var writers []io.Writer + for _, dest := range config.Destinations { + switch dest.Type { + case Stdout: + writers = append(writers, os.Stdout) + case File: + filePath := dest.Config["path"] + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return newLogger, fmt.Errorf("failed to create log directory: %v", err) + } + lumberjackLogger := &lumberjack.Logger{ + Filename: filePath, + Compress: false, + } + absPath, err := filepath.Abs(filePath) + if err != nil { + return newLogger, fmt.Errorf("failed to get absolute path: %v", err) + } + lumberjackLogger.Filename = absPath + + setConfigValue := func(key string, target *int) { + if valStr, ok := dest.Config[key]; ok { + if val, err := strconv.Atoi(valStr); err == nil { + *target = val + } + } + } + setConfigValue("maxSize", &lumberjackLogger.MaxSize) + setConfigValue("maxBackups", &lumberjackLogger.MaxBackups) + setConfigValue("maxAge", &lumberjackLogger.MaxAge) + if compress, ok := dest.Config["compress"]; ok { + lumberjackLogger.Compress = compress == "true" + } + writers = append(writers, lumberjackLogger) + } + } + multiwriter := io.MultiWriter(writers...) + defer func() { + if closer, ok := multiwriter.(io.Closer); ok { + closer.Close() + } + }() + newLogger = zerolog.New(multiwriter). + Level(logLevels[config.Level]). + With(). + Timestamp(). + Logger() + + cfg = config + return newLogger, nil +} + +func InitLogger(c Config) error { + var initErr error + once.Do(func() { + if initErr = c.validate(); initErr != nil { + return + } + + logger, initErr = getLogger(c) + }) + return initErr +} + +func Debug(ctx context.Context, msg string) { + logEvent(ctx, zerolog.DebugLevel, msg, nil) +} + +func Debugf(ctx context.Context, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.DebugLevel, msg, nil) +} + +func Info(ctx context.Context, msg string) { + logEvent(ctx, zerolog.InfoLevel, msg, nil) +} + +func Infof(ctx context.Context, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.InfoLevel, msg, nil) +} + +func Warn(ctx context.Context, msg string) { + logEvent(ctx, zerolog.WarnLevel, msg, nil) +} + +func Warnf(ctx context.Context, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.WarnLevel, msg, nil) +} + +func Error(ctx context.Context, err error, msg string) { + logEvent(ctx, zerolog.ErrorLevel, msg, err) +} + +func Errorf(ctx context.Context, err error, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.ErrorLevel, msg, err) +} + +func Fatal(ctx context.Context, err error, msg string) { + logEvent(ctx, zerolog.FatalLevel, msg, err) +} + +func Fatalf(ctx context.Context, err error, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.FatalLevel, msg, err) +} + +func Panic(ctx context.Context, err error, msg string) { + logEvent(ctx, zerolog.PanicLevel, msg, err) +} + +func Panicf(ctx context.Context, err error, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + logEvent(ctx, zerolog.PanicLevel, msg, err) +} + +func logEvent(ctx context.Context, level zerolog.Level, msg string, err error) { + event := logger.WithLevel(level) + + if err != nil { + event = event.Err(err) + } + addCtx(ctx, event) + event.Msg(msg) +} + +func Request(ctx context.Context, r *http.Request, body []byte) { + event := logger.Info() + addCtx(ctx, event) + event.Str("method", r.Method). + Str("url", r.URL.String()). + Str("body", string(body)). + Str("remoteAddr", r.RemoteAddr). + Msg("HTTP Request") +} + +func addCtx(ctx context.Context, event *zerolog.Event) { + for _, key := range cfg.ContextKeys { + val, ok := ctx.Value(key).(string) + if !ok { + continue + } + keyStr := key + event.Any(keyStr, val) + } +} + +func Response(ctx context.Context, r *http.Request, statusCode int, responseTime time.Duration) { + event := logger.Info() + addCtx(ctx, event) + event.Str("method", r.Method). + Str("url", r.URL.String()). + Int("statusCode", statusCode). + Dur("responseTime", responseTime). + Msg("HTTP Response") +} diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go new file mode 100644 index 0000000..2e874ae --- /dev/null +++ b/pkg/log/log_test.go @@ -0,0 +1,667 @@ +package log + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type ctxKey any + +var requestID ctxKey = "requestID" +var userID ctxKey = "userID" + +const testLogFilePath = "./test_logs/test.log" + +func TestMain(m *testing.M) { + // Create a single temporary directory for all tests + var err error + dir := filepath.Dir(testLogFilePath) + err = os.MkdirAll(dir, os.ModePerm) + if err != nil { + panic("failed to create test log directory: " + err.Error()) + } + + // Run all tests + code := m.Run() + + // Cleanup: Remove the log directory after all tests finish + err = os.RemoveAll(dir) + if err != nil { + println("failed to clean up test log directory: ", err.Error()) + } + + // Exit with the appropriate exit code + os.Exit(code) +} + +func setupLogger(t *testing.T, l level) string { + t.Helper() + + // Create a temporary directory for logs. + + config := Config{ + Level: l, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": testLogFilePath, + "maxSize": "1", + "maxAge": "1", + "maxBackup": "1", + "compress": "false", + }, + }, + }, + ContextKeys: []string{"userID", "requestID"}, + } + + // Initialize logger with the given config + err := InitLogger(config) + if err != nil { + t.Fatalf("failed to initialize logger: %v", err) + } + + return testLogFilePath +} + +func readLogFile(t *testing.T, logPath string) []string { + t.Helper() + b, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read log file: %v", err) + } + return strings.Split(string(b), "\n") +} + +func parseLogLine(t *testing.T, line string) map[string]interface{} { + t.Helper() + var logEntry map[string]interface{} + err := json.Unmarshal([]byte(line), &logEntry) + if err != nil { + t.Fatalf("Failed to parse log line: %v", err) + } + return logEntry +} + +func TestDebug(t *testing.T) { + t.Helper() + logPath := setupLogger(t, DebugLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Debug(ctx, "Debug message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "debug", + "userID": "12345", + "message": "Debug message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + + if !found { + t.Errorf("Expected Debug message, but it was not found in logs") + } +} + +func TestInfo(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Info(ctx, "Info message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "info", + "userID": "12345", + "message": "Info message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + + if !found { + t.Errorf("expected Info message, but it was not found in logs") + } +} + +func TestWarn(t *testing.T) { + logPath := setupLogger(t, WarnLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Warn(ctx, "Warning message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "warn", + "userID": "12345", + "message": "Warning message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + delete(logEntry, "time") + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Warning message, but it was not found in logs") + } +} + +func TestError(t *testing.T) { + logPath := setupLogger(t, ErrorLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Error(ctx, fmt.Errorf("test error"), "Error message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "error", + "userID": "12345", + "message": "Error message", + "error": "test error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Error message, but it was not found in logs") + } +} + +func TestRequest(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), requestID, "abc-123") + req, _ := http.NewRequest("POST", "/api/test", bytes.NewBuffer([]byte(`{"key":"value"}`))) + req.RemoteAddr = "127.0.0.1:8080" + Request(ctx, req, []byte(`{"key":"value"}`)) + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + if logEntry["message"] == "HTTP Request" || logEntry["method"] == "POST" { + found = true + break + } + } + if !found { + t.Errorf("expected formatted debug message, but it was not found in logs") + } +} + +func TestResponse(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), requestID, "abc-123") + req, _ := http.NewRequest("GET", "/api/test", nil) + Response(ctx, req, 200, time.Millisecond*123) + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + if logEntry["message"] == "HTTP Response" { + if logEntry["message"] == "HTTP Response" { + value, ok := logEntry["statusCode"] + if !ok { + t.Fatalf("Expected key 'statusCode' not found in log entry") + } + statusCode, ok := value.(float64) + if !ok { + t.Fatalf("Value for 'statusCode' is not a float64, found: %T", value) + } + if statusCode == 200 { + found = true + break + } + } + } + } + if !found { + t.Errorf("expected message, but it was not found in logs") + } +} + +func TestFatal(t *testing.T) { + logPath := setupLogger(t, FatalLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Fatal(ctx, fmt.Errorf("fatal error"), "Fatal message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "fatal", + "userID": "12345", + "message": "Fatal message", + "error": "fatal error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Fatal message, but it was not found in logs") + } +} + +func TestPanic(t *testing.T) { + logPath := setupLogger(t, PanicLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Panic(ctx, fmt.Errorf("panic error"), "Panic message") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "panic", + "userID": "12345", + "message": "Panic message", + "error": "panic error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Panic message, but it was not found in logs") + } +} + +func TestDebugf(t *testing.T) { + logPath := setupLogger(t, DebugLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Debugf(ctx, "Debugf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "debug", + "userID": "12345", + "message": "Debugf message: test", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + t.Log(line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected formatted debug message, but it was not found in logs") + } +} + +func TestInfof(t *testing.T) { + logPath := setupLogger(t, InfoLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Infof(ctx, "Infof message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "info", + "userID": "12345", + "message": "Infof message: test", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Infof message, but it was not found in logs") + } +} + +func TestWarnf(t *testing.T) { + logPath := setupLogger(t, WarnLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + Warnf(ctx, "Warnf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "warn", + "userID": "12345", + "message": "Warnf message: test", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Warnf message, but it was not found in logs") + } +} + +func TestErrorf(t *testing.T) { + logPath := setupLogger(t, ErrorLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + err := fmt.Errorf("error message") + Errorf(ctx, err, "Errorf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "error", + "userID": "12345", + "message": "Errorf message: test", + "error": "error message", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Errorf message, but it was not found in logs") + } +} + +func TestFatalf(t *testing.T) { + logPath := setupLogger(t, FatalLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + err := fmt.Errorf("fatal error") + Fatalf(ctx, err, "Fatalf message: %s", "test") + lines := readLogFile(t, logPath) + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "fatal", + "userID": "12345", + "message": "Fatalf message: test", + "error": "fatal error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + if !found { + t.Errorf("expected Fatalf message, but it was not found in logs") + } +} + +func TestPanicf(t *testing.T) { + logPath := setupLogger(t, PanicLevel) + ctx := context.WithValue(context.Background(), userID, "12345") + err := fmt.Errorf("panic error") + Panicf(ctx, err, "Panicf message: %s", "test") + lines := readLogFile(t, logPath) + + if len(lines) == 0 { + t.Fatal("No logs were written.") + } + expected := map[string]interface{}{ + "level": "panic", + "userID": "12345", + "message": "Panicf message: test", + "error": "panic error", + } + + var found bool + for _, line := range lines { + logEntry := parseLogLine(t, line) + // Ignore 'time' while comparing + delete(logEntry, "time") + + if reflect.DeepEqual(expected, logEntry) { + found = true + break + } + } + + if !found { + t.Errorf("expected Panicf message, but it was not found in logs") + } +} + +func TestValidateConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr error + }{ + { + name: "Valid config with Stdout", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + {Type: Stdout}, + }, + }, + wantErr: nil, + }, + { + name: "Valid config with File destination and valid path", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxSize": "10", + "maxBackups": "5", + "maxAge": "7", + }, + }, + }, + }, + wantErr: nil, + }, + { + name: "Error: Invalid log level", + config: Config{ + Level: "invalid", + Destinations: []destination{ + {Type: Stdout}, + }, + }, + wantErr: ErrInvalidLogLevel, + }, + { + name: "Error: No destinations provided", + config: Config{ + Level: InfoLevel, + Destinations: []destination{}, + }, + wantErr: ErrLogDestinationNil, + }, + { + name: "Error: Invalid destination type", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + {Type: "unknown"}, + }, + }, + wantErr: fmt.Errorf("invalid destination type 'unknown'"), + }, + { + name: "Error: Missing file path for file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "maxSize": "10", + }, + }, + }, + }, + wantErr: ErrMissingFilePath, + }, + { + name: "Error: Invalid maxSize value in file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxSize": "invalid", + }, + }, + }, + }, + wantErr: errors.New(`invalid maxSize: strconv.Atoi: parsing "invalid": invalid syntax`), + }, + { + name: "Error: Invalid maxBackups value in file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxBackups": "invalid", + }, + }, + }, + }, + wantErr: errors.New(`invalid maxBackups: strconv.Atoi: parsing "invalid": invalid syntax`), + }, + { + name: "Error: Invalid maxAge value in file destination", + config: Config{ + Level: InfoLevel, + Destinations: []destination{ + { + Type: File, + Config: map[string]string{ + "path": "./logs/app.log", + "maxAge": "invalid", + }, + }, + }, + }, + wantErr: errors.New(`invalid maxAge: strconv.Atoi: parsing "invalid": invalid syntax`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validate() + if (err == nil) != (tt.wantErr == nil) { + t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && tt.wantErr != nil && err.Error() != tt.wantErr.Error() { + t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}