Files
loggerx/loggerx_fixes_test.go
T

381 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loggerx_test
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/yuninks/loggerx"
)
// newTestLogger 统一构造测试实例,测试结束关闭句柄
func newTestLogger(t *testing.T, opts ...loggerx.Option) (*loggerx.Logger, string) {
t.Helper()
dir := t.TempDir()
opts = append([]loggerx.Option{loggerx.SetDir(dir)}, opts...)
l := loggerx.NewLogger(context.Background(), opts...)
t.Cleanup(func() {
if err := l.Close(); err != nil {
t.Errorf("Close 返回错误: %v", err)
}
})
return l, dir
}
// syncAndRead 先刷盘再读取(写入走 bufio,落盘后才能看到)
func syncAndRead(t *testing.T, l *loggerx.Logger, dir, event string) string {
t.Helper()
if err := l.MustSync(); err != nil {
t.Fatalf("MustSync: %v", err)
}
return readLog(t, dir, event)
}
// tailOf 取字符串末尾 n 个字节,用于失败时打印现场
func tailOf(s string, n int) string {
if len(s) <= n {
return s
}
return s[len(s)-n:]
}
// readLog 读取切割后的日志文件(文件名可能带日期前缀)
func readLog(t *testing.T, dir, event string) string {
t.Helper()
files, err := filepath.Glob(filepath.Join(dir, "*_"+event+".log"))
if err != nil {
t.Fatalf("glob %s: %v", event, err)
}
if len(files) == 0 {
files, _ = filepath.Glob(filepath.Join(dir, event+".log"))
}
var sb strings.Builder
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s: %v", f, err)
}
sb.Write(b)
}
return sb.String()
}
// ---------------- 修复1:文件句柄缓存键必须包含 event ----------------
// info/error 交替写入后,各自文件的内容必须完整且不串台
func TestEventHandlerIsolation(t *testing.T) {
l, dir := newTestLogger(t)
const n = 50
for i := 0; i < n; i++ {
l.Infof(context.Background(), "INFO-%d", i)
l.Errorf(context.Background(), "ERR-%d", i)
}
info, errl := syncAndRead(t, l, dir, "info"), syncAndRead(t, l, dir, "error")
if got := strings.Count(info, "INFO-"); got != n {
t.Errorf("info.log 条数 = %d, 期望 %d", got, n)
}
if got := strings.Count(errl, "ERR-"); got != n {
t.Errorf("error.log 条数 = %d, 期望 %d", got, n)
}
if strings.Contains(info, "ERR-") {
t.Error("info.log 中混入了 error 日志")
}
if strings.Contains(errl, "INFO-") {
t.Error("error.log 中混入了 info 日志")
}
}
// 不同 channel 也必须各自独立
func TestChannelIsolation(t *testing.T) {
l, dir := newTestLogger(t)
l.Channel("c1").Info(context.Background(), "IN-C1")
l.Channel("c2").Info(context.Background(), "IN-C2")
if s := syncAndRead(t, l, dir, "info"); strings.Contains(s, "IN-C2") {
t.Error("根 channel 的日志里混入了 c2 的内容")
}
// channel 的日志在子目录里,同样带时间前缀
c1, err := filepath.Glob(filepath.Join(dir, "c1", "*_info.log"))
if err != nil || len(c1) == 0 {
t.Fatalf("没有找到 c1 的日志文件: %v", err)
}
b, err := os.ReadFile(c1[0])
if err != nil {
t.Fatalf("读取 c1 日志: %v", err)
}
if !strings.Contains(string(b), "IN-C1") {
t.Errorf("c1 日志内容不正确: %s", string(b))
}
}
// 事件交替写入的性能:不能因为切换事件而反复重开文件
func BenchmarkInfoOnly(b *testing.B) {
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(b.TempDir()))
defer l.Close()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
}
}
func BenchmarkInfoErrorInterleaved(b *testing.B) {
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(b.TempDir()))
defer l.Close()
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
l.Infof(ctx, "hello %d", i)
l.Errorf(ctx, "hello %d", i)
}
}
// ---------------- 修复2io.Writer 契约 ----------------
func TestWriteSatisfiesIOWriterContract(t *testing.T) {
cases := []struct {
name string
opts []loggerx.Option
}{
{"默认(写文件)", nil},
{"只写驱动(不写文件)", []loggerx.Option{loggerx.SetPrintFile(false), loggerx.SetToConsole()}},
{"文件+附加驱动", []loggerx.Option{loggerx.SetExtraDriver(io.Discard)}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
l, _ := newTestLogger(t, c.opts...)
b := []byte("hello io.Writer\n")
n, err := l.Write(b)
if err != nil {
t.Fatalf("Write 返回错误: %v", err)
}
if n != len(b) {
t.Fatalf("Write 返回 n=%d, 期望 len(b)=%d(违反 io.Writer 契约)", n, len(b))
}
})
}
}
// 通过标准库 log 写入也必须成功:log 在短写时会报 io.ErrShortWrite
func TestStdLogWriteNoShortWriteError(t *testing.T) {
l, dir := newTestLogger(t)
// log.SetOutput 是全局状态,恢复现场
prev := log.Writer()
defer log.SetOutput(prev)
log.SetOutput(l)
if err := log.Output(2, "via standard log"); err != nil {
t.Fatalf("标准 log 写入失败: %v", err)
}
if s := syncAndRead(t, l, dir, "info"); !strings.Contains(s, "via standard log") {
t.Errorf("日志内容未落盘: %s", s)
}
}
// ---------------- 修复3:句柄释放与刷盘 ----------------
// Close 之后文件必须能被删除(Windows 上句柄没关是删不掉的)
func TestCloseReleasesFileHandles(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
l.Info(context.Background(), "before close")
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
if len(files) == 0 {
t.Fatal("没有生成日志文件")
}
for _, f := range files {
if err := os.Remove(f); err != nil {
t.Errorf("Close 后仍无法删除 %s: %v", filepath.Base(f), err)
}
}
}
// Close 必须先把缓存里的内容刷到磁盘
func TestCloseFlushesBufferedData(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir))
l.Info(context.Background(), "buffered-content")
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
if len(files) != 1 {
t.Fatalf("期望 1 个日志文件, 实际 %d", len(files))
}
b, err := os.ReadFile(files[0])
if err != nil {
t.Fatalf("读取: %v", err)
}
if !strings.Contains(string(b), "buffered-content") {
t.Errorf("Close 后内容丢失: %q", string(b))
}
}
// 重复 Close 必须安全
func TestCloseIsIdempotent(t *testing.T) {
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(t.TempDir()))
if err := l.Close(); err != nil {
t.Fatalf("首次 Close: %v", err)
}
if err := l.Close(); err != nil {
t.Fatalf("重复 Close: %v", err)
}
}
// 异步写入 + Close:队列里的内容不能丢
func TestCloseDrainsAsyncQueue(t *testing.T) {
for _, n := range []int{10, 100, 197, 198, 199, 200, 201, 250} {
t.Run(fmt.Sprintf("n=%d", n), func(t *testing.T) {
dir := t.TempDir()
l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetWriteAsync())
al := l.WriteAsync()
for i := 0; i < n; i++ {
al.Infof(context.Background(), "ASYNC-%d", i)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
var sb strings.Builder
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s: %v", f, err)
}
sb.Write(b)
}
content := sb.String()
missing := make([]string, 0)
for i := 0; i < n; i++ {
if !strings.Contains(content, fmt.Sprintf(`ASYNC-%d"`, i)) {
missing = append(missing, fmt.Sprint(i))
}
}
if len(missing) > 0 {
cnt := strings.Count(content, "[info]")
t.Errorf("丢 %d 条: %s | 实际日志行数=%d n=%d 结尾=%q", len(missing), strings.Join(missing, ","), cnt, n, tailOf(content, 160))
}
})
}
}
// 用 context.Background 创建实例,Close 后后台 goroutine 必须退出
func TestCloseStopsGoroutines(t *testing.T) {
before := runtime.NumGoroutine()
loggers := make([]*loggerx.Logger, 0, 10)
for i := 0; i < 10; i++ {
loggers = append(loggers, loggerx.NewLogger(context.Background(), loggerx.SetDir(t.TempDir())))
}
for _, l := range loggers {
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Errorf("Close 后 goroutine 未回收: before=%d after=%d", before, runtime.NumGoroutine())
}
// ---------------- 修复4nil ctx / 序列化失败 ----------------
func TestNilContextDoesNotPanic(t *testing.T) {
l, dir := newTestLogger(t)
// nolint:staticcheck // 故意传 nil,验证不 panic
l.Info(nil, "nil ctx")
if s := syncAndRead(t, l, dir, "info"); !strings.Contains(s, "nil ctx") {
t.Errorf("nil ctx 时日志未写入: %q", s)
}
}
// 不可序列化的内容不能产出空行 / 坏 JSON
func TestUnmarshalableContentProducesValidJSON(t *testing.T) {
l, dir := newTestLogger(t)
l.Info(context.Background(), make(chan int), func() {}, map[string]any{"ok": 1})
l.Infof(context.Background(), "后面这条必须还在")
content := syncAndRead(t, l, dir, "info")
lines := strings.Split(strings.TrimSpace(content), "\n")
if len(lines) != 2 {
t.Fatalf("期望 2 行日志, 实际 %d 行: %q", len(lines), content)
}
for i, line := range lines {
body := strings.TrimPrefix(line, "[info]")
if body == "" {
t.Fatalf("第 %d 行是空的 [info] 裸行(序列化失败被静默丢弃)", i+1)
}
var v map[string]any
if err := json.Unmarshal([]byte(body), &v); err != nil {
t.Fatalf("第 %d 行不是合法 JSON: %v\n内容: %s", i+1, err, body)
}
if i == 0 && v["content"] == nil {
t.Error("序列化失败时 content 丢失,无法定位问题")
}
}
}
// 并发写入:数据不能串、不能丢
func TestConcurrentWriteIntegrity(t *testing.T) {
l, dir := newTestLogger(t)
var wg sync.WaitGroup
const perLevel = 300
for i := 0; i < perLevel; i++ {
wg.Add(3)
go func(i int) { defer wg.Done(); l.Infof(context.Background(), "INFO-%d", i) }(i)
go func(i int) { defer wg.Done(); l.Errorf(context.Background(), "ERR-%d", i) }(i)
go func(i int) { defer wg.Done(); l.Channel("cc").Infof(context.Background(), "CC-%d", i) }(i)
}
wg.Wait()
if got := strings.Count(syncAndRead(t, l, dir, "info"), "INFO-"); got != perLevel {
t.Errorf("info 条数 = %d, 期望 %d", got, perLevel)
}
if got := strings.Count(syncAndRead(t, l, dir, "error"), "ERR-"); got != perLevel {
t.Errorf("error 条数 = %d, 期望 %d", got, perLevel)
}
// cc 是子 channel,日志落在 cc/ 子目录
ccFiles, _ := filepath.Glob(filepath.Join(dir, "cc", "*_info.log"))
if len(ccFiles) == 0 {
t.Fatal("没有找到 cc 的日志文件")
}
var ccb strings.Builder
for _, f := range ccFiles {
b, _ := os.ReadFile(f)
ccb.Write(b)
}
if got := strings.Count(ccb.String(), "CC-"); got != perLevel {
t.Errorf("cc 条数 = %d, 期望 %d", got, perLevel)
}
}