package loggerx_test import ( "compress/gzip" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/yuninks/loggerx" ) // ---------- 功能 1:定时刷盘 ---------- // 开启定时刷盘后,未 Close 也能在磁盘上看到日志 func TestFlushIntervalPersistsWithoutClose(t *testing.T) { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetFlushInterval(30*time.Millisecond), ) defer l.Close() l.Info(context.Background(), "FLUSHED-BY-TIMER") // 等若干次刷盘周期;不调用 MustSync,也不 Close deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if strings.Contains(readAllLogs(t, dir), "FLUSHED-BY-TIMER") { return // 成功:定时器把它刷下去了 } time.Sleep(20 * time.Millisecond) } t.Fatal("定时刷盘没生效:未 Close 时磁盘上看不到日志") } // 不开定时刷盘时,日志应该仍留在内存缓冲里(证明上面的测试不是假通过) func TestNoFlushIntervalKeepsInBuffer(t *testing.T) { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir)) defer l.Close() l.Info(context.Background(), "STILL-BUFFERED") time.Sleep(200 * time.Millisecond) if strings.Contains(readAllLogs(t, dir), "STILL-BUFFERED") { t.Skip("本次写入正好触发了缓冲落盘,跳过该对照") } } // 定时刷盘不能丢日志、也不能让 Close 之后 goroutine 残留 func TestFlushIntervalNoLossAndStops(t *testing.T) { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetFlushInterval(10*time.Millisecond), ) const n = 3000 for i := 0; i < n; i++ { l.Infof(context.Background(), "FL-%d", i) } if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } content := readAllLogs(t, dir) missing := 0 for i := 0; i < n; i++ { if !strings.Contains(content, fmt.Sprintf(`FL-%d"`, i)) { missing++ } } if missing > 0 { t.Errorf("定时刷盘场景丢了 %d 条日志", missing) } } // ---------- 功能 2:按大小切割 + 压缩 ---------- // 超过大小上限后应该滚动出带序号的归档文件,并压缩成 .gz func TestSizeSplitRollsAndCompresses(t *testing.T) { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetSizeSplit(8*1024), // 8KB 一个文件 ) const n = 800 // 每条约 130 字节 => 约 104KB,应滚出十来个文件 for i := 0; i < n; i++ { l.Infof(context.Background(), "SPLIT-%d-%s", i, strings.Repeat("x", 60)) } if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } files, _ := filepath.Glob(filepath.Join(dir, "*.log")) gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz")) t.Logf("滚动结果: %d 个 .log, %d 个 .gz", len(files), len(gzs)) if len(gzs) == 0 { t.Fatal("没有产生任何压缩归档 .gz") } // 每个未压缩文件都不应明显超过上限(留一点余量给缓冲/单条超长) for _, f := range files { st, err := os.Stat(f) if err != nil { t.Fatalf("stat %s: %v", f, err) } if st.Size() > 8*1024+2048 { t.Errorf("%s 大小 %d 超过上限过多", filepath.Base(f), st.Size()) } } // 归档文件名应带序号 for _, g := range gzs { if !strings.Contains(filepath.Base(g), "_") { t.Errorf("归档文件名缺少序号: %s", filepath.Base(g)) } } } // 压缩归档要能被解开,且内容是完整合法的日志(不能丢条) func TestCompressedArchiveIsReadable(t *testing.T) { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetSizeSplit(4*1024), ) const n = 400 for i := 0; i < n; i++ { l.Infof(context.Background(), "GZ-%d", i) } if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz")) if len(gzs) == 0 { t.Fatal("没有产生 .gz 归档") } // 压缩归档 + 当前未压缩文件一起统计,必须一条不漏 content := readAllGz(t, dir) + readAllLogs(t, dir) // 落到磁盘的每条记录都应是合法 JSON 行(便于采集端解析) if !jsonLinesValid(t, content) { t.Errorf("归档内容不是合法 JSON 行: %.160q", content) } missing := 0 for i := 0; i < n; i++ { if !strings.Contains(content, fmt.Sprintf(`GZ-%d"`, i)) { missing++ } } if missing > 0 { t.Errorf("压缩归档丢失 %d / %d 条日志", missing, n) } t.Logf("共 %d 个 .gz 归档,解压后日志完整(%d 条)", len(gzs), n) } // 关闭压缩时,归档应保持未压缩的 .log 形态 func TestSizeSplitWithoutCompress(t *testing.T) { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetSizeSplit(4*1024), loggerx.SetCompress(false), ) for i := 0; i < 300; i++ { l.Infof(context.Background(), "RAW-%d", i) } if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz")) if len(gzs) != 0 { t.Errorf("SetCompress(false) 仍然产生了 %d 个 .gz", len(gzs)) } files, _ := filepath.Glob(filepath.Join(dir, "*.log")) if len(files) < 2 { t.Errorf("期望滚出多个归档文件,实际 %d 个", len(files)) } } // 滚动过程中不能丢日志:所有序号都应在 .log 或 .gz 里找得到 func TestSizeSplitNoLoss(t *testing.T) { for round := 0; round < 3; round++ { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetSizeSplit(8*1024), ) const n = 600 for i := 0; i < n; i++ { l.Infof(context.Background(), "NOLOSS-%d", i) } if err := l.Close(); err != nil { t.Fatalf("第 %d 轮 Close: %v", round, err) } content := readAllLogs(t, dir) + readAllGz(t, dir) missing := 0 for i := 0; i < n; i++ { if !strings.Contains(content, fmt.Sprintf(`NOLOSS-%d"`, i)) { missing++ } } if missing > 0 { t.Errorf("第 %d 轮:按大小切割丢了 %d 条日志", round, missing) } } } // 单条日志就超过上限时不能死循环 func TestSizeSplitHugeSingleEntry(t *testing.T) { dir := t.TempDir() l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetSizeSplit(1024), // 上限比单条还小 ) done := make(chan struct{}) go func() { defer close(done) l.Info(context.Background(), strings.Repeat("H", 8192)) l.Info(context.Background(), "after-huge") }() select { case <-done: case <-time.After(10 * time.Second): t.Fatal("单条超长日志导致卡死(可能是滚动死循环)") } if err := l.Close(); err != nil { t.Fatalf("Close: %v", err) } if !strings.Contains(readAllLogs(t, dir)+readAllGz(t, dir), "after-huge") { t.Error("超长日志之后的那条日志丢失了") } } // 重启场景:文件已经满了,新进程开起来应该归档旧文件而不是永远写同一个 func TestSizeSplitOnRestart(t *testing.T) { dir := t.TempDir() opts := []loggerx.Option{loggerx.SetDir(dir), loggerx.SetSizeSplit(4 * 1024)} l1 := loggerx.NewLogger(context.Background(), opts...) for i := 0; i < 200; i++ { l1.Infof(context.Background(), "RUN1-%d", i) } if err := l1.Close(); err != nil { t.Fatalf("l1.Close: %v", err) } l2 := loggerx.NewLogger(context.Background(), opts...) for i := 0; i < 200; i++ { l2.Infof(context.Background(), "RUN2-%d", i) } if err := l2.Close(); err != nil { t.Fatalf("l2.Close: %v", err) } content := readAllLogs(t, dir) + readAllGz(t, dir) for i := 0; i < 200; i++ { if !strings.Contains(content, fmt.Sprintf(`RUN2-%d"`, i)) { t.Fatalf("重启后 RUN2-%d 丢失", i) } } // 归档序号不应互相覆盖:RUN1 与 RUN2 都应该在 if !strings.Contains(content, `RUN1-0"`) { t.Error("第一轮运行的日志被覆盖了") } } // jsonLinesValid 校验每行都是合法 JSON(空行忽略) func jsonLinesValid(t *testing.T, content string) bool { t.Helper() lines := strings.Split(strings.TrimSpace(content), "\n") if len(lines) == 0 { return false } for i, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } var v map[string]any if err := json.Unmarshal([]byte(line), &v); err != nil { t.Logf("第 %d 行不是合法 JSON: %v\n%s", i+1, err, line) return false } if v["level"] == nil { t.Logf("第 %d 行缺少 level 字段: %s", i+1, line) return false } } return true } // readAllGz 解开目录下所有 .gz 归档 func readAllGz(t *testing.T, dir string) string { t.Helper() var sb strings.Builder gzs, _ := filepath.Glob(filepath.Join(dir, "*.gz")) for _, g := range gzs { f, err := os.Open(g) if err != nil { t.Fatalf("打开 %s: %v", g, err) } zr, err := gzip.NewReader(f) if err != nil { _ = f.Close() t.Fatalf("%s 不是合法 gzip: %v", g, err) } b, err := io.ReadAll(zr) if err != nil { t.Fatalf("解压 %s: %v", g, err) } _ = zr.Close() _ = f.Close() sb.Write(b) } return sb.String() }