package logger import ( "bufio" "encoding/json" "io" "os" "strings" "sync" "testing" ) // serializeLoggerTests guards the global mutations (os.Stdout, globalLogger, // zerolog global). These tests cannot run in parallel with each other. var serializeLoggerTests sync.Mutex // captureStdout swaps os.Stdout for a pipe, calls fn, restores stdout, and // returns everything written during fn. func captureStdout(t *testing.T, fn func()) string { t.Helper() r, w, err := os.Pipe() if err != nil { t.Fatalf("os.Pipe: %v", err) } old := os.Stdout os.Stdout = w t.Cleanup(func() { os.Stdout = old }) // Run fn and close writer so the reader unblocks. doneWrite := make(chan struct{}) go func() { fn() _ = w.Close() close(doneWrite) }() var buf strings.Builder scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { buf.WriteString(scanner.Text()) buf.WriteByte('\n') } // Drain any residual (shouldn't happen after Close, but safe): _, _ = io.Copy(io.Discard, r) <-doneWrite return buf.String() } func TestInit_JSONFormatProducesParseableOutput(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() out := captureStdout(t, func() { Init(Config{Level: "info", Format: "json"}) Info().Str("user", "alice").Msg("hello") }) // Find the first non-empty line; parse as JSON. var line string for l := range strings.SplitSeq(out, "\n") { if strings.TrimSpace(l) != "" { line = l break } } if line == "" { t.Fatalf("no log output captured; stdout = %q", out) } var parsed map[string]any if err := json.Unmarshal([]byte(line), &parsed); err != nil { t.Fatalf("log line is not valid JSON: %v\nline: %s", err, line) } // Field assertions — zerolog uses "message" for the msg and "level" for level. if got, _ := parsed["message"].(string); got != "hello" { t.Errorf("message = %v, want hello", parsed["message"]) } if got, _ := parsed["user"].(string); got != "alice" { t.Errorf("user field = %v, want alice", parsed["user"]) } if got, _ := parsed["level"].(string); got != "info" { t.Errorf("level = %v, want info", parsed["level"]) } if _, ok := parsed["time"]; !ok { t.Errorf("expected time field; got keys %v", keysOf(parsed)) } if _, ok := parsed["caller"]; !ok { t.Errorf("expected caller field; got keys %v", keysOf(parsed)) } } func TestInit_LevelFilterDropsBelowThreshold(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() out := captureStdout(t, func() { Init(Config{Level: "warn", Format: "json"}) Debug().Msg("debug-dropped") Info().Msg("info-dropped") Warn().Msg("warn-kept") Error().Msg("error-kept") }) if strings.Contains(out, "debug-dropped") { t.Errorf("debug event leaked through warn filter: %s", out) } if strings.Contains(out, "info-dropped") { t.Errorf("info event leaked through warn filter: %s", out) } if !strings.Contains(out, "warn-kept") { t.Errorf("warn event missing: %s", out) } if !strings.Contains(out, "error-kept") { t.Errorf("error event missing: %s", out) } } func TestInit_UnknownLevelDefaultsToInfo(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() out := captureStdout(t, func() { Init(Config{Level: "gibberish", Format: "json"}) Debug().Msg("debug-should-be-dropped") Info().Msg("info-should-appear") }) if strings.Contains(out, "debug-should-be-dropped") { t.Errorf("debug leaked at default info level: %s", out) } if !strings.Contains(out, "info-should-appear") { t.Errorf("info missing at default info level: %s", out) } } func TestInit_TextFormatDoesNotCrashAndIsNotJSON(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() out := captureStdout(t, func() { Init(Config{Level: "info", Format: "text"}) Info().Str("k", "v").Msg("plain") }) if !strings.Contains(out, "plain") { t.Errorf("text output missing message: %s", out) } // Console writer output is ANSI-colored key=value form, not JSON. var parsed map[string]any if json.Unmarshal([]byte(strings.Split(out, "\n")[0]), &parsed) == nil { t.Errorf("text format unexpectedly parsed as JSON: %s", out) } } func TestGet_AutoInitializesWhenUnused(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() // Forcibly clear the global so Get() hits the lazy-init branch. globalLogger = nil l := Get() if l == nil { t.Fatal("Get() returned nil; lazy init did not run") } if globalLogger == nil { t.Fatal("globalLogger still nil after Get()") } } func TestWithComponent_AddsComponentField(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() out := captureStdout(t, func() { Init(Config{Level: "info", Format: "json"}) comp := WithComponent("buckets") comp.Info().Msg("tagged") }) line := firstNonEmptyLine(out) var parsed map[string]any if err := json.Unmarshal([]byte(line), &parsed); err != nil { t.Fatalf("not JSON: %v — %s", err, line) } if got, _ := parsed["component"].(string); got != "buckets" { t.Errorf("component = %v, want buckets", parsed["component"]) } } func TestLogger_WithContext_AddsFields(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() out := captureStdout(t, func() { Init(Config{Level: "info", Format: "json"}) l := Get().WithContext(map[string]any{ "request_id": "req-42", "attempt": 2, }) l.Info().Msg("ctx") }) line := firstNonEmptyLine(out) var parsed map[string]any if err := json.Unmarshal([]byte(line), &parsed); err != nil { t.Fatalf("not JSON: %v — %s", err, line) } if parsed["request_id"] != "req-42" { t.Errorf("request_id = %v", parsed["request_id"]) } // JSON numbers decode to float64. if got, _ := parsed["attempt"].(float64); got != 2 { t.Errorf("attempt = %v, want 2", parsed["attempt"]) } } func TestWithError_AttachesErrorField(t *testing.T) { serializeLoggerTests.Lock() defer serializeLoggerTests.Unlock() out := captureStdout(t, func() { Init(Config{Level: "error", Format: "json"}) WithError(io.EOF).Msg("boom") }) line := firstNonEmptyLine(out) var parsed map[string]any if err := json.Unmarshal([]byte(line), &parsed); err != nil { t.Fatalf("not JSON: %v — %s", err, line) } if got, _ := parsed["error"].(string); got != io.EOF.Error() { t.Errorf("error field = %v, want %q", parsed["error"], io.EOF.Error()) } if got, _ := parsed["level"].(string); got != "error" { t.Errorf("level = %v, want error", parsed["level"]) } } // --- helpers --- func firstNonEmptyLine(s string) string { for l := range strings.SplitSeq(s, "\n") { if strings.TrimSpace(l) != "" { return l } } return "" } func keysOf(m map[string]any) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out }