Files
log/config_test.go
T

194 lines
7.3 KiB
Go

package log
import (
"strings"
"sync"
"testing"
"time"
)
// TestDefaultConfig verifies default values and copy independence.
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
equal(t, cfg.Level, LevelInfo, "Level")
equal(t, cfg.Name, "log", "Name")
equal(t, cfg.Extension, "log", "Extension")
equal(t, cfg.Directory, "./log", "Directory")
equal(t, cfg.Format, "raw", "Format")
equal(t, cfg.Sanitization, PolicyRaw, "Sanitization")
equal(t, cfg.ConsoleTarget, "stderr", "ConsoleTarget")
equal(t, cfg.TimestampFormat, time.RFC3339Nano, "TimestampFormat")
equal(t, cfg.BufferSize, int64(1024), "BufferSize")
isTrue(t, cfg.ShowTimestamp, "ShowTimestamp")
isTrue(t, cfg.ShowLevel, "ShowLevel")
isTrue(t, cfg.EnableConsole, "EnableConsole")
isFalse(t, cfg.EnableFile, "EnableFile")
noErr(t, cfg.Validate(), "default config must validate")
// Each call must yield an independent copy of the package-level default
other := DefaultConfig()
if cfg == other {
t.Error("DefaultConfig returned a shared pointer")
}
cfg.Level = LevelError
equal(t, other.Level, LevelInfo, "second copy must be unaffected")
}
// TestConfigClone verifies full-value copy and bidirectional independence.
func TestConfigClone(t *testing.T) {
src := DefaultConfig()
src.Level = LevelDebug
src.Directory = "/custom/path"
src.RetentionPeriodHrs = 12.5
dst := src.Clone()
mustEqual(t, *dst, *src, "clone must equal source")
src.Level = LevelError
equal(t, dst.Level, LevelDebug, "clone unaffected by source mutation")
dst.Name = "renamed"
equal(t, src.Name, "log", "source unaffected by clone mutation")
}
// TestConfigValidate covers each validation branch.
func TestConfigValidate(t *testing.T) {
tests := []struct {
name string
modify func(*Config)
wantError string
}{
{"valid config", func(c *Config) {}, ""},
{"empty name", func(c *Config) { c.Name = "" }, "log name cannot be empty"},
{"whitespace name", func(c *Config) { c.Name = " " }, "log name cannot be empty"},
{"invalid format", func(c *Config) { c.Format = "invalid" }, "invalid format"},
{"invalid sanitization", func(c *Config) { c.Sanitization = "bogus" }, "invalid sanitization policy"},
{"extension with dot", func(c *Config) { c.Extension = ".log" }, "extension should not start with dot"},
{"empty timestamp format", func(c *Config) { c.TimestampFormat = " " }, "timestamp_format cannot be empty"},
{"invalid console target", func(c *Config) { c.ConsoleTarget = "invalid" }, "invalid console_target"},
{"zero buffer size", func(c *Config) { c.BufferSize = 0 }, "buffer_size must be positive"},
{"negative buffer size", func(c *Config) { c.BufferSize = -1 }, "buffer_size must be positive"},
{"negative max size", func(c *Config) { c.MaxSizeKB = -1 }, "size limits cannot be negative"},
{"negative total size", func(c *Config) { c.MaxTotalSizeKB = -1 }, "size limits cannot be negative"},
{"negative min disk free", func(c *Config) { c.MinDiskFreeKB = -1 }, "size limits cannot be negative"},
{"zero flush interval", func(c *Config) { c.FlushIntervalMs = 0 }, "interval settings must be positive"},
{"zero disk check interval", func(c *Config) { c.DiskCheckIntervalMs = 0 }, "interval settings must be positive"},
{"negative trace depth", func(c *Config) { c.TraceDepth = -1 }, "trace_depth must be between 0 and 10"},
{"excessive trace depth", func(c *Config) { c.TraceDepth = 11 }, "trace_depth must be between 0 and 10"},
{"boundary trace depth", func(c *Config) { c.TraceDepth = 10 }, ""},
{"negative retention", func(c *Config) { c.RetentionPeriodHrs = -1 }, "retention settings cannot be negative"},
{"invalid heartbeat level", func(c *Config) { c.HeartbeatLevel = 4 }, "heartbeat_level must be between 0 and 3"},
{
name: "heartbeat enabled without interval",
modify: func(c *Config) { c.HeartbeatLevel = 1; c.HeartbeatIntervalS = 0 },
wantError: "heartbeat_interval_s must be positive",
},
{
name: "min greater than max check interval",
modify: func(c *Config) { c.MinCheckIntervalMs = 1000; c.MaxCheckIntervalMs = 500 },
wantError: "min_check_interval_ms",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DefaultConfig()
tt.modify(cfg)
err := cfg.Validate()
if tt.wantError == "" {
noErr(t, err, "Validate")
return
}
errContains(t, err, tt.wantError, "Validate")
})
}
}
// TestConfigRequiresRestart verifies which field changes force a processor restart.
func TestConfigRequiresRestart(t *testing.T) {
tests := []struct {
name string
modify func(*Config)
want bool
}{
{"no change", func(c *Config) {}, false},
{"level", func(c *Config) { c.Level = LevelError }, false},
{"format", func(c *Config) { c.Format = "json" }, false},
{"sanitization", func(c *Config) { c.Sanitization = PolicyTxt }, false},
{"trace depth", func(c *Config) { c.TraceDepth = 3 }, false},
{"console target", func(c *Config) { c.ConsoleTarget = "stdout" }, false},
{"buffer size", func(c *Config) { c.BufferSize = 2048 }, true},
{"enable file", func(c *Config) { c.EnableFile = !c.EnableFile }, true},
{"directory", func(c *Config) { c.Directory = "/other" }, true},
{"name", func(c *Config) { c.Name = "other" }, true},
{"extension", func(c *Config) { c.Extension = "txt" }, true},
{"flush interval", func(c *Config) { c.FlushIntervalMs = 500 }, true},
{"heartbeat level", func(c *Config) { c.HeartbeatLevel = 2 }, true},
{"retention period", func(c *Config) { c.RetentionPeriodHrs = 4 }, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldCfg := DefaultConfig()
newCfg := oldCfg.Clone()
tt.modify(newCfg)
equal(t, configRequiresRestart(oldCfg, newCfg), tt.want, "configRequiresRestart")
})
}
}
// TestCombineConfigErrors verifies aggregation and prefix deduplication.
func TestCombineConfigErrors(t *testing.T) {
if err := combineConfigErrors(nil); err != nil {
t.Errorf("empty slice: got %v, want nil", err)
}
single := fmtErrorf("only one")
mustEqual(t, combineConfigErrors([]error{single}), single, "single error passthrough")
err := combineConfigErrors([]error{fmtErrorf("first"), fmtErrorf("second")})
mustErr(t, err, "combineConfigErrors")
msg := err.Error()
contains(t, msg, "multiple configuration errors", "header")
contains(t, msg, "1. first", "first entry")
contains(t, msg, "2. second", "second entry")
// Per-error "log: " prefixes must be stripped, leaving only the header prefix
equal(t, strings.Count(msg, "log: "), 1, "prefix occurrences")
}
// TestConcurrentApplyConfig verifies reconfiguration under concurrent load.
func TestConcurrentApplyConfig(t *testing.T) {
logger, tmpDir := newTestLogger(t)
var wg sync.WaitGroup
for i := range 10 {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg := logger.GetConfig()
if id%2 == 0 {
cfg.Level, cfg.Format = LevelDebug, "json"
} else {
cfg.Level, cfg.Format = LevelInfo, "txt"
}
cfg.TraceDepth = int64(id % 5)
// Non-fatal only: Fatal from a non-test goroutine is undefined behavior
noErr(t, logger.ApplyConfig(cfg), "concurrent ApplyConfig")
logger.Info("config test", id)
}(i)
}
wg.Wait()
logger.Info("after concurrent config")
noErr(t, logger.Flush(time.Second), "Flush")
mustEventually(t, time.Second, "post-reconfiguration record written", func() bool {
return strings.Contains(readLog(t, tmpDir), "after concurrent config")
})
}