feat: 添加 zap 日志工厂和 GORM 日志桥接

- NewLogger 工厂函数:支持 JSON/Console 编码、stdout/文件/多输出、lumberjack 轮转

- NewGormLogger 实现 gorm.Interface:Trace 区分错误/慢查询/正常查询

- output_stdout 用 *bool 三态处理(nil=true, true, false)

- 默认值:level=info, encoding=json, max_size=100, max_backups=5, max_age=30

- 慢查询阈值 200ms,ErrRecordNotFound 不视为错误

- 编译时接口检查: var _ gormlogger.Interface = (*GormLogger)(nil)

- 完整 TDD 测试覆盖

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-10 08:39:21 +08:00
parent 7550e75945
commit f7a21ee455
4 changed files with 1036 additions and 0 deletions

148
internal/logger/gorm.go Normal file
View File

@@ -0,0 +1,148 @@
package logger
import (
"context"
"errors"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
const slowQueryThreshold = 200 * time.Millisecond
// GormLogger implements gorm's logger.Interface backed by zap.
type GormLogger struct {
logger *zap.Logger
level zapcore.Level
silent bool
}
// Compile-time interface check.
var _ gormlogger.Interface = (*GormLogger)(nil)
// NewGormLogger creates a new GormLogger wrapping the given zap logger.
// The level string maps to zap levels; empty defaults to "warn".
// The special value "silent" suppresses all output.
func NewGormLogger(zapLogger *zap.Logger, level string) gormlogger.Interface {
lvl := parseGormLevel(level)
silent := level == "silent"
return &GormLogger{
logger: zapLogger,
level: lvl,
silent: silent,
}
}
// LogMode returns a new GormLogger with the given gorm log level.
// It does NOT mutate the receiver.
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
newLogger := &GormLogger{
logger: l.logger,
level: l.level,
silent: l.silent,
}
switch level {
case gormlogger.Silent:
newLogger.silent = true
case gormlogger.Error:
newLogger.level = zapcore.ErrorLevel
newLogger.silent = false
case gormlogger.Warn:
newLogger.level = zapcore.WarnLevel
newLogger.silent = false
case gormlogger.Info:
newLogger.level = zapcore.InfoLevel
newLogger.silent = false
}
return newLogger
}
// Info logs at zap.InfoLevel with structured fields from key-value pairs.
func (l *GormLogger) Info(ctx context.Context, msg string, args ...any) {
if l.silent || l.level > zapcore.InfoLevel {
return
}
l.logger.Info(msg, argsToFields(args)...)
}
// Warn logs at zap.WarnLevel with structured fields from key-value pairs.
func (l *GormLogger) Warn(ctx context.Context, msg string, args ...any) {
if l.silent || l.level > zapcore.WarnLevel {
return
}
l.logger.Warn(msg, argsToFields(args)...)
}
// Error logs at zap.ErrorLevel with structured fields from key-value pairs.
func (l *GormLogger) Error(ctx context.Context, msg string, args ...any) {
if l.silent || l.level > zapcore.ErrorLevel {
return
}
l.logger.Error(msg, argsToFields(args)...)
}
// Trace logs SQL query information based on execution results.
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
if l.silent {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
switch {
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
l.logger.Error("gorm query error",
zap.Error(err),
zap.String("sql", sql),
zap.Int64("rows", rows),
zap.Duration("elapsed", elapsed),
)
case elapsed > slowQueryThreshold:
l.logger.Warn("gorm slow query",
zap.String("sql", sql),
zap.Int64("rows", rows),
zap.Float64("elapsed_ms", float64(elapsed.Nanoseconds())/1e6),
)
default:
if l.level > zapcore.InfoLevel {
return
}
l.logger.Info("gorm query",
zap.String("sql", sql),
zap.Int64("rows", rows),
zap.Duration("elapsed", elapsed),
)
}
}
// parseGormLevel parses a level string into a zapcore.Level.
// Defaults to zapcore.WarnLevel.
func parseGormLevel(level string) zapcore.Level {
if level == "" || level == "silent" {
return zapcore.WarnLevel
}
var lvl zapcore.Level
if err := lvl.UnmarshalText([]byte(level)); err != nil {
return zapcore.WarnLevel
}
return lvl
}
// argsToFields converts alternating key-value pairs into zap fields.
func argsToFields(args []any) []zap.Field {
fields := make([]zap.Field, 0, len(args)/2)
for i := 0; i+1 < len(args); i += 2 {
key, ok := args[i].(string)
if !ok {
continue
}
fields = append(fields, zap.Any(key, args[i+1]))
}
return fields
}

View File

@@ -0,0 +1,509 @@
package logger
import (
"context"
"errors"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// newObservedLogger creates a zap logger backed by an observer for test assertions.
func newObservedLogger() (*zap.Logger, *observer.ObservedLogs) {
core, recorded := observer.New(zapcore.DebugLevel)
return zap.New(core), recorded
}
func TestNewGormLogger_DefaultLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
if g.level != zapcore.WarnLevel {
t.Fatalf("expected warn level, got %v", g.level)
}
if g.silent {
t.Fatal("expected silent=false")
}
}
func TestNewGormLogger_Silent(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "silent")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
if !g.silent {
t.Fatal("expected silent=true")
}
}
func TestNewGormLogger_ExplicitLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "error")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
if g.level != zapcore.ErrorLevel {
t.Fatalf("expected error level, got %v", g.level)
}
}
func TestNewGormLogger_InvalidLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "bogus")
g, ok := gl.(*GormLogger)
if !ok {
t.Fatal("expected *GormLogger")
}
// Should default to warn
if g.level != zapcore.WarnLevel {
t.Fatalf("expected warn level fallback, got %v", g.level)
}
}
func TestGormLogger_Info(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
gl.Info(context.Background(), "test info", "key", "value")
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 log entry, got %d", len(entries))
}
if entries[0].Message != "test info" {
t.Fatalf("expected message 'test info', got %q", entries[0].Message)
}
if entries[0].Level != zapcore.InfoLevel {
t.Fatalf("expected InfoLevel, got %v", entries[0].Level)
}
}
func TestGormLogger_Warn(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
gl.Warn(context.Background(), "test warn", "code", 42)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 log entry, got %d", len(entries))
}
if entries[0].Message != "test warn" {
t.Fatalf("expected message 'test warn', got %q", entries[0].Message)
}
if entries[0].Level != zapcore.WarnLevel {
t.Fatalf("expected WarnLevel, got %v", entries[0].Level)
}
}
func TestGormLogger_Error(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "error")
gl.Error(context.Background(), "test error", "module", "gorm")
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 log entry, got %d", len(entries))
}
if entries[0].Message != "test error" {
t.Fatalf("expected message 'test error', got %q", entries[0].Message)
}
if entries[0].Level != zapcore.ErrorLevel {
t.Fatalf("expected ErrorLevel, got %v", entries[0].Level)
}
}
func TestGormLogger_LevelFiltering(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
// Info should be suppressed at warn level
gl.Info(context.Background(), "should be suppressed")
if len(recorded.All()) != 0 {
t.Fatal("info should be suppressed at warn level")
}
// Warn should pass
gl.Warn(context.Background(), "should pass")
if len(recorded.All()) != 1 {
t.Fatal("warn should pass at warn level")
}
}
func TestGormLogger_SilentSuppressesAll(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "silent")
gl.Info(context.Background(), "info msg")
gl.Warn(context.Background(), "warn msg")
gl.Error(context.Background(), "error msg")
if len(recorded.All()) != 0 {
t.Fatalf("silent mode should suppress all logs, got %d", len(recorded.All()))
}
}
func TestGormLogger_LogMode_ReturnsNewInstance(t *testing.T) {
log, _ := newObservedLogger()
original := NewGormLogger(log, "warn").(*GormLogger)
modified := original.LogMode(gormlogger.Info)
// Must be a different instance
if modified == original {
t.Fatal("LogMode should return a new instance")
}
// Original should be unchanged
if original.level != zapcore.WarnLevel {
t.Fatalf("original level should remain warn, got %v", original.level)
}
// New instance should have InfoLevel
mod := modified.(*GormLogger)
if mod.level != zapcore.InfoLevel {
t.Fatalf("modified level should be info, got %v", mod.level)
}
}
func TestGormLogger_LogMode_Silent(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "info").(*GormLogger)
silent := gl.LogMode(gormlogger.Silent).(*GormLogger)
if !silent.silent {
t.Fatal("LogMode(Silent) should set silent=true")
}
// Original should not be silent
if gl.silent {
t.Fatal("original should not be affected")
}
}
func TestGormLogger_LogMode_ErrorLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "info").(*GormLogger)
modified := gl.LogMode(gormlogger.Error).(*GormLogger)
if modified.level != zapcore.ErrorLevel {
t.Fatalf("expected ErrorLevel, got %v", modified.level)
}
}
func TestGormLogger_LogMode_WarnLevel(t *testing.T) {
log, _ := newObservedLogger()
gl := NewGormLogger(log, "info").(*GormLogger)
modified := gl.LogMode(gormlogger.Warn).(*GormLogger)
if modified.level != zapcore.WarnLevel {
t.Fatalf("expected WarnLevel, got %v", modified.level)
}
}
func TestGormLogger_Trace_WithError(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users", 0 }
err := errors.New("connection refused")
gl.Trace(context.Background(), begin, fc, err)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level != zapcore.ErrorLevel {
t.Fatalf("expected ErrorLevel for real errors, got %v", entries[0].Level)
}
if entries[0].Message != "gorm query error" {
t.Fatalf("unexpected message: %q", entries[0].Message)
}
}
func TestGormLogger_Trace_ErrRecordNotFound(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users WHERE id = ?", 0 }
gl.Trace(context.Background(), begin, fc, gorm.ErrRecordNotFound)
// ErrRecordNotFound should NOT be logged as error
// At default level with no slow query, it should log as info
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level == zapcore.ErrorLevel {
t.Fatal("ErrRecordNotFound should NOT be logged at ErrorLevel")
}
if entries[0].Message != "gorm query" {
t.Fatalf("expected 'gorm query', got %q", entries[0].Message)
}
}
func TestGormLogger_Trace_ErrRecordNotFound_SuppressedAtWarnLevel(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users WHERE id = ?", 0 }
gl.Trace(context.Background(), begin, fc, gorm.ErrRecordNotFound)
// ErrRecordNotFound is not a real error, so it falls through to the default path.
// At warn level, the default path (info-level) is suppressed.
entries := recorded.All()
if len(entries) != 0 {
t.Fatalf("expected 0 entries at warn level, got %d", len(entries))
}
}
func TestGormLogger_Trace_SlowQuery(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
// Simulate a begin time far enough in the past to exceed the threshold
begin := time.Now().Add(-500 * time.Millisecond)
fc := func() (string, int64) { return "SELECT SLEEP(1)", 1 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level != zapcore.WarnLevel {
t.Fatalf("expected WarnLevel for slow query, got %v", entries[0].Level)
}
if entries[0].Message != "gorm slow query" {
t.Fatalf("expected 'gorm slow query', got %q", entries[0].Message)
}
}
func TestGormLogger_Trace_NormalQuery(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
begin := time.Now()
fc := func() (string, int64) { return "SELECT 1", 1 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Level != zapcore.InfoLevel {
t.Fatalf("expected InfoLevel for normal query, got %v", entries[0].Level)
}
if entries[0].Message != "gorm query" {
t.Fatalf("expected 'gorm query', got %q", entries[0].Message)
}
}
func TestGormLogger_Trace_NormalQuerySuppressedAtWarnLevel(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "SELECT 1", 1 }
gl.Trace(context.Background(), begin, fc, nil)
// Normal queries are info-level, suppressed at warn
if len(recorded.All()) != 0 {
t.Fatal("normal query should be suppressed at warn level")
}
}
func TestGormLogger_Trace_SilentSuppressesAll(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "silent")
begin := time.Now().Add(-500 * time.Millisecond)
fc := func() (string, int64) { return "SELECT SLEEP(1)", 1 }
err := errors.New("some error")
gl.Trace(context.Background(), begin, fc, err)
if len(recorded.All()) != 0 {
t.Fatal("silent mode should suppress even Trace errors")
}
}
func TestGormLogger_Trace_StructuredFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
begin := time.Now()
fc := func() (string, int64) { return "SELECT * FROM users", 42 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
// Verify structured fields
fields := entries[0].ContextMap()
if fields["sql"] != "SELECT * FROM users" {
t.Fatalf("expected sql field, got %v", fields["sql"])
}
if fields["rows"] != int64(42) {
t.Fatalf("expected rows=42, got %v", fields["rows"])
}
if _, ok := fields["elapsed"]; !ok {
t.Fatal("expected elapsed field")
}
}
func TestGormLogger_Trace_ErrorFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now()
fc := func() (string, int64) { return "INSERT INTO users", 0 }
err := errors.New("duplicate key")
gl.Trace(context.Background(), begin, fc, err)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["sql"] != "INSERT INTO users" {
t.Fatalf("expected sql field, got %v", fields["sql"])
}
if _, ok := fields["error"]; !ok {
t.Fatal("expected error field")
}
}
func TestGormLogger_Trace_SlowQueryFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "warn")
begin := time.Now().Add(-500 * time.Millisecond)
fc := func() (string, int64) { return "SELECT * FROM large_table", 1000 }
gl.Trace(context.Background(), begin, fc, nil)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["sql"] != "SELECT * FROM large_table" {
t.Fatalf("expected sql field, got %v", fields["sql"])
}
if fields["rows"] != int64(1000) {
t.Fatalf("expected rows=1000, got %v", fields["rows"])
}
if _, ok := fields["elapsed_ms"]; !ok {
t.Fatal("expected elapsed_ms field for slow query")
}
}
func TestArgsToFields(t *testing.T) {
tests := []struct {
name string
args []interface{}
expected int
}{
{"empty", nil, 0},
{"single_pair", []interface{}{"key", "value"}, 1},
{"two_pairs", []interface{}{"a", 1, "b", 2}, 2},
{"odd_args_ignores_last", []interface{}{"key", "value", "orphan"}, 1},
{"non_string_key_ignored", []interface{}{123, "value"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := argsToFields(tt.args)
if len(fields) != tt.expected {
t.Fatalf("expected %d fields, got %d", tt.expected, len(fields))
}
})
}
}
func TestArgsToFields_FieldValues(t *testing.T) {
fields := argsToFields([]interface{}{"name", "test", "count", 42})
if len(fields) != 2 {
t.Fatalf("expected 2 fields, got %d", len(fields))
}
if fields[0].Key != "name" {
t.Fatalf("expected key 'name', got %q", fields[0].Key)
}
if fields[1].Key != "count" {
t.Fatalf("expected key 'count', got %q", fields[1].Key)
}
}
func TestParseGormLevel(t *testing.T) {
tests := []struct {
input string
expected zapcore.Level
}{
{"debug", zapcore.DebugLevel},
{"info", zapcore.InfoLevel},
{"warn", zapcore.WarnLevel},
{"error", zapcore.ErrorLevel},
{"", zapcore.WarnLevel},
{"silent", zapcore.WarnLevel},
{"invalid", zapcore.WarnLevel},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := parseGormLevel(tt.input)
if got != tt.expected {
t.Fatalf("expected %v, got %v", tt.expected, got)
}
})
}
}
func TestGormLogger_InfoWithMultipleFields(t *testing.T) {
log, recorded := newObservedLogger()
gl := NewGormLogger(log, "info")
gl.Info(context.Background(), "multi fields", "key1", "val1", "key2", 123, "key3", true)
entries := recorded.All()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
fields := entries[0].ContextMap()
if fields["key1"] != "val1" {
t.Fatalf("expected key1=val1, got %v", fields["key1"])
}
if fields["key2"] != int64(123) {
t.Fatalf("expected key2=123, got %v", fields["key2"])
}
if fields["key3"] != true {
t.Fatalf("expected key3=true, got %v", fields["key3"])
}
}

93
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,93 @@
package logger
import (
"fmt"
"os"
"gcy_hpc_server/internal/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
func NewLogger(cfg config.LogConfig) (*zap.Logger, error) {
level := applyDefault(cfg.Level, "info")
var zapLevel zapcore.Level
if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
return nil, fmt.Errorf("invalid log level %q: %w", level, err)
}
encoding := applyDefault(cfg.Encoding, "json")
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "ts"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
var encoder zapcore.Encoder
switch encoding {
case "console":
encoder = zapcore.NewConsoleEncoder(encoderConfig)
default:
encoder = zapcore.NewJSONEncoder(encoderConfig)
}
var syncers []zapcore.WriteSyncer
stdout := true
if cfg.OutputStdout != nil {
stdout = *cfg.OutputStdout
}
if stdout {
syncers = append(syncers, zapcore.AddSync(os.Stdout))
}
if cfg.FilePath != "" {
maxSize := applyDefaultInt(cfg.MaxSize, 100)
maxBackups := applyDefaultInt(cfg.MaxBackups, 5)
maxAge := applyDefaultInt(cfg.MaxAge, 30)
compress := cfg.Compress || cfg.MaxSize == 0 && cfg.MaxBackups == 0 && cfg.MaxAge == 0
lj := &lumberjack.Logger{
Filename: cfg.FilePath,
MaxSize: maxSize,
MaxBackups: maxBackups,
MaxAge: maxAge,
Compress: compress,
}
syncers = append(syncers, zapcore.AddSync(lj))
}
if len(syncers) == 0 {
syncers = append(syncers, zapcore.AddSync(os.Stdout))
}
writeSyncer := syncers[0]
if len(syncers) > 1 {
writeSyncer = zapcore.NewMultiWriteSyncer(syncers...)
}
core := zapcore.NewCore(encoder, writeSyncer, zapLevel)
opts := []zap.Option{
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
}
return zap.New(core, opts...), nil
}
func applyDefault(val, def string) string {
if val == "" {
return def
}
return val
}
func applyDefaultInt(val, def int) int {
if val == 0 {
return def
}
return val
}

View File

@@ -0,0 +1,286 @@
package logger
import (
"os"
"path/filepath"
"strings"
"testing"
"gcy_hpc_server/internal/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest"
)
func ptrBool(v bool) *bool { return &v }
// TestNewLogger_JSONConfig creates a logger with JSON encoding and verifies
// that log entries are emitted successfully.
func TestNewLogger_JSONConfig(t *testing.T) {
cfg := config.LogConfig{
Level: "debug",
Encoding: "json",
OutputStdout: ptrBool(true),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
// Should not panic when logging
log.Info("json logger test", zap.String("key", "value"))
}
// TestNewLogger_ConsoleConfig creates a logger with console encoding.
func TestNewLogger_ConsoleConfig(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "console",
OutputStdout: ptrBool(true),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("console logger test", zap.Int("num", 42))
}
// TestNewLogger_InvalidLevel verifies that an invalid log level returns an error.
func TestNewLogger_InvalidLevel(t *testing.T) {
cfg := config.LogConfig{
Level: "bogus",
Encoding: "json",
}
_, err := NewLogger(cfg)
if err == nil {
t.Fatal("expected error for invalid log level, got nil")
}
}
// TestNewLogger_EmptyConfig verifies defaults are applied when config is zero-value.
func TestNewLogger_EmptyConfig(t *testing.T) {
cfg := config.LogConfig{} // all zero values
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("default config test")
}
// TestNewLogger_FileOutput verifies that file output with rotation config works.
func TestNewLogger_FileOutput(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "test.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
FilePath: logFile,
MaxSize: 10,
MaxBackups: 3,
MaxAge: 7,
Compress: true,
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("file output test", zap.String("msg", "hello"))
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if len(data) == 0 {
t.Fatal("log file is empty, expected output")
}
if !strings.Contains(string(data), "file output test") {
t.Fatalf("log file content does not contain expected message;\ngot: %s", string(data))
}
}
// TestNewLogger_MultiWriter verifies that both stdout and file output work together.
func TestNewLogger_MultiWriter(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "multi.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
OutputStdout: ptrBool(true),
FilePath: logFile,
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("multi writer test", zap.String("writer", "both"))
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if !strings.Contains(string(data), "multi writer test") {
t.Fatalf("log file content does not contain expected message;\ngot: %s", string(data))
}
}
// TestNewLogger_Observer verifies actual log output content using zaptest.
func TestNewLogger_Observer(t *testing.T) {
// Use zaptest.NewLogger to capture logs in test output
log := zaptest.NewLogger(t,
zaptest.WrapOptions(zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)),
zaptest.Level(zapcore.DebugLevel),
)
// These should all succeed without panicking
log.Debug("debug msg", zap.String("k", "v"))
log.Info("info msg", zap.Int("n", 1))
log.Warn("warn msg")
log.Error("error msg")
}
// TestNewLogger_AllLevels verifies all valid log levels parse correctly.
func TestNewLogger_AllLevels(t *testing.T) {
levels := []string{"debug", "info", "warn", "error", "dpanic", "panic", "fatal"}
for _, level := range levels {
t.Run(level, func(t *testing.T) {
cfg := config.LogConfig{
Level: level,
Encoding: "json",
OutputStdout: ptrBool(true),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("level %q: NewLogger returned error: %v", level, err)
}
log.Sync()
})
}
}
// TestNewLogger_InvalidEncoding falls back gracefully — the factory should
// treat an unrecognized encoding as an error or default to JSON.
func TestNewLogger_InvalidEncoding(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "xml",
OutputStdout: ptrBool(true),
}
// The implementation should default to JSON for unknown encoding.
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("unexpected error for invalid encoding: %v", err)
}
defer log.Sync()
log.Info("invalid encoding test")
}
// TestNewLogger_DefaultRotation verifies rotation defaults are applied.
func TestNewLogger_DefaultRotation(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "rotation.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
FilePath: logFile,
// MaxSize, MaxBackups, MaxAge, Compress all zero → defaults apply
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("rotation defaults test")
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if len(data) == 0 {
t.Fatal("log file is empty")
}
}
func TestNewLogger_OutputStdoutNil(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("default stdout test")
}
func TestNewLogger_OutputStdoutFalseWithFile(t *testing.T) {
tmpDir := t.TempDir()
logFile := filepath.Join(tmpDir, "nostdout.log")
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
OutputStdout: ptrBool(false),
FilePath: logFile,
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
log.Info("file only test")
log.Sync()
data, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if !strings.Contains(string(data), "file only test") {
t.Fatalf("log file content does not contain expected message;\ngot: %s", string(data))
}
}
func TestNewLogger_OutputStdoutFalseFallback(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Encoding: "json",
OutputStdout: ptrBool(false),
}
log, err := NewLogger(cfg)
if err != nil {
t.Fatalf("NewLogger returned error: %v", err)
}
defer log.Sync()
log.Info("fallback stdout test")
}