Files
log/integration_test.go
T

165 lines
4.7 KiB
Go

package log
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// TestFullLifecycle exercises builder construction, every log entry point,
// runtime reconfiguration, and heartbeat emission end to end.
func TestFullLifecycle(t *testing.T) {
tmpDir := t.TempDir()
logger, err := NewBuilder().
Directory(tmpDir).
LevelString("debug").
Format("json").
MaxSizeKB(1).
BufferSize(1000).
EnableConsole(false).
EnableFile(true).
HeartbeatLevel(3).
HeartbeatIntervalS(1).
Build()
mustNoErr(t, err, "Build")
if logger == nil {
t.Fatal("Build returned a nil logger without an error")
}
mustNoErr(t, logger.Start(), "Start")
t.Cleanup(func() { noErr(t, logger.Shutdown(2*time.Second), "Shutdown") })
logger.Debug("debug message")
logger.Info("info message")
logger.Warn("warning message")
logger.Error("error message")
logger.LogStructured(LevelInfo, "structured log", map[string]any{
"user_id": 123,
"action": "login",
"success": true,
})
logger.Write("raw data write")
logger.InfoTrace(2, "trace info")
mustNoErr(t, logger.ApplyConfigString("console_target=stderr", "trace_depth=1"), "ApplyConfigString")
logger.Info("after reconfiguration")
// MaxSizeKB=1 forces rotation, so assertions span every file in the directory
mustEventually(t, 3*time.Second, "proc heartbeat emitted", func() bool {
return strings.Contains(readAllLogs(t, tmpDir), `"type":"proc"`)
})
mustNoErr(t, logger.Flush(time.Second), "Flush")
content := readAllLogs(t, tmpDir)
contains(t, content, `"level":"DEBUG"`, "debug level record")
contains(t, content, `"message":"structured log"`, "structured message key")
contains(t, content, `"user_id":123`, "structured field")
contains(t, content, "raw data write", "raw write")
contains(t, content, "after reconfiguration", "post-reconfiguration record")
contains(t, content, `"type":"disk"`, "disk heartbeat")
contains(t, content, `"type":"sys"`, "sys heartbeat")
files, err := os.ReadDir(tmpDir)
mustNoErr(t, err, "ReadDir")
if len(files) < 1 {
t.Error("no log files created")
}
}
// TestConcurrentOperations verifies stability under simultaneous logging,
// reconfiguration, and flushing.
func TestConcurrentOperations(t *testing.T) {
logger, _ := newTestLogger(t)
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range 20 {
logger.Info("worker", id, "log", j)
}
}(i)
}
wg.Add(1)
go func() {
defer wg.Done()
for i := range 3 {
// Non-fatal only: Fatal outside the test goroutine is undefined behavior
noErr(t, logger.ApplyConfigString(fmt.Sprintf("trace_depth=%d", i)), "ApplyConfigString")
time.Sleep(50 * time.Millisecond)
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for range 5 {
// Timeout must exceed worst-case contention on flushMutex under load
noErr(t, logger.Flush(2*time.Second), "concurrent Flush")
time.Sleep(30 * time.Millisecond)
}
}()
wg.Wait()
noErr(t, logger.Flush(2*time.Second), "final Flush")
}
// TestErrorRecovery covers construction and runtime failure paths.
func TestErrorRecovery(t *testing.T) {
t.Run("unwritable directory", func(t *testing.T) {
// Directory mode is not enforced against uid 0
if os.Geteuid() == 0 {
t.Skip("running as root; directory permissions are not enforced")
}
parent := t.TempDir()
mustNoErr(t, os.Chmod(parent, 0o500), "chmod parent")
t.Cleanup(func() { _ = os.Chmod(parent, 0o700) })
logger, err := NewBuilder().
Directory(filepath.Join(parent, "nested")).
EnableFile(true).
Build()
errContains(t, err, "failed to create log directory", "Build")
if logger != nil {
t.Error("Build must return a nil logger on failure")
}
})
t.Run("disk full", func(t *testing.T) {
logger, _ := newTestLogger(t)
cfg := logger.GetConfig()
cfg.MinDiskFreeKB = 1 << 40 // unsatisfiable free-space requirement
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
isFalse(t, logger.performDiskCheck(true), "performDiskCheck under simulated disk full")
isFalse(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK")
preDropped := logger.state.DroppedLogs.Load()
logger.Info("this log entry should be dropped")
// The processor drops asynchronously after dequeuing
mustEventually(t, time.Second, "drop counter incremented", func() bool {
return logger.state.DroppedLogs.Load() > preDropped
})
// Recovery: restoring the threshold must clear the failure state
cfg = logger.GetConfig()
cfg.MinDiskFreeKB = 0
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig recovery")
isTrue(t, logger.performDiskCheck(true), "performDiskCheck after recovery")
isTrue(t, logger.state.DiskStatusOK.Load(), "DiskStatusOK after recovery")
})
}