package logging import ( "bytes" "log/slog" "os" "path/filepath" "strings" "testing" "time" ) // newTestLogging 装配一套写进临时目录的日志,返回 logger 与关闭函数。 func newTestLogging(t *testing.T, dir string, level slog.Level, maxSizeMB, keepFiles int) (*slog.Logger, *bytes.Buffer, func()) { t.Helper() console := &bytes.Buffer{} handler, closer := New(Options{ Dir: dir, Level: level, MaxSizeMB: maxSizeMB, KeepFiles: keepFiles, Console: console, }) t.Cleanup(func() { _ = closer.Close() }) return slog.New(handler), console, func() { _ = closer.Close() } } // readLog 读某类日志今天的文件内容;文件不存在返回空串。 func readLog(t *testing.T, dir, prefix string) string { t.Helper() path := filepath.Join(dir, prefix, prefix+"-"+time.Now().Format("20060102")+".log") raw, err := os.ReadFile(path) if os.IsNotExist(err) { return "" } if err != nil { t.Fatalf("读日志 %s: %v", path, err) } return string(raw) } // 两类分流:放行类进 operations/,拦截类(WARN/上下文/异常)进 interceptions/,互不串门。 func TestRoutesOperationsAndInterceptions(t *testing.T) { dir := t.TempDir() logger, console, closeLogs := newTestLogging(t, dir, slog.LevelInfo, 20, 10) logger.Info("操作已放行", "action", "team.create", "actor", "recorder#1") logger.Warn("启动告警") // 没带标记的 WARN 属于常规运行日志 logger.Debug("http", "path", "/api/teams") // 默认 info 级别下不落盘 logger.Warn("请求被拦截", "reason", "无权访问该资源", "origin", "internal/middleware/middleware.go:126 RequireRole", ChannelKey, ChannelInterception) logger.Debug("拦截上下文", "code", "FORBIDDEN", "origin", "internal/middleware/middleware.go:126 RequireRole", ChannelKey, ChannelInterception) logger.Error("请求失败", "code", "INTERNAL", "origin", "internal/httpapi/handlers_results.go:238 displayCommandHandler") closeLogs() ops := readLog(t, dir, DirOperations) inter := readLog(t, dir, DirInterceptions) for _, want := range []string{"操作已放行", "启动告警", "team.create"} { if !strings.Contains(ops, want) { t.Fatalf("operations 应含 %q,得到:\n%s", want, ops) } } for _, unwanted := range []string{"请求被拦截", "拦截上下文", "请求失败", `"msg":"http"`} { if strings.Contains(ops, unwanted) { t.Fatalf("operations 不该含 %q,得到:\n%s", unwanted, ops) } } for _, want := range []string{"请求被拦截", "拦截上下文", "请求失败", "RequireRole", "无权访问该资源"} { if !strings.Contains(inter, want) { t.Fatalf("interceptions 应含 %q,得到:\n%s", want, inter) } } for _, unwanted := range []string{"操作已放行", "启动告警", `"msg":"http"`} { if strings.Contains(inter, unwanted) { t.Fatalf("interceptions 不该含 %q,得到:\n%s", unwanted, inter) } } // 控制台保持现状:全量按级别输出。 if !strings.Contains(console.String(), "操作已放行") || !strings.Contains(console.String(), "请求被拦截") { t.Fatalf("控制台应同时看到两类日志,得到:\n%s", console.String()) } } // 拦截类固定按 DEBUG 采集:把级别压到 error,拦截的 WARN 与上下文 DEBUG 仍要落盘。 func TestInterceptionsIgnoreConfiguredLevel(t *testing.T) { dir := t.TempDir() logger, _, closeLogs := newTestLogging(t, dir, slog.LevelError, 20, 10) logger.Info("操作已放行", "action", "team.create") logger.Warn("启动告警") logger.Warn("请求被拦截", "reason", "请先登录", "origin", "internal/middleware/middleware.go:140 RequireWrite", ChannelKey, ChannelInterception) logger.Debug("拦截上下文", "code", "AUTH_REQUIRED", ChannelKey, ChannelInterception) closeLogs() ops := readLog(t, dir, DirOperations) inter := readLog(t, dir, DirInterceptions) if strings.Contains(ops, "操作已放行") || strings.Contains(ops, "启动告警") { t.Fatalf("级别为 error 时 operations 不该有 INFO/WARN,得到:\n%s", ops) } if !strings.Contains(inter, "请求被拦截") || !strings.Contains(inter, "拦截上下文") { t.Fatalf("拦截类不受 log.level 影响,应仍有 WARN 与 DEBUG,得到:\n%s", inter) } } // 轮转与保留:超过大小上限续写 .1/.2,超出保留份数的最旧文件被清理。 func TestRotatorRotatesAndPrunes(t *testing.T) { dir := t.TempDir() w := newRotator(dir, "operations", 120, 3, nil) defer func() { _ = w.Close() }() // 每行约 30 字节,写 12 行 → 至少 3 个文件,保留 3 份后仍不该无限涨。 line := []byte("0123456789012345678901234567\n") for i := 0; i < 12; i++ { if _, err := w.Write(line); err != nil { t.Fatalf("写日志: %v", err) } } files := w.list() names := make([]string, 0, len(files)) for _, f := range files { names = append(names, f.name) } if len(files) != 3 { t.Fatalf("应轮转出 3 个文件(保留 3 份),得到 %v", names) } day := time.Now().Format("20060102") want := []string{"operations-" + day + ".log", "operations-" + day + ".1.log", "operations-" + day + ".2.log"} if strings.Join(names, ",") != strings.Join(want, ",") { t.Fatalf("文件名应为 %v,得到 %v", want, names) } // 每个文件都不超过上限(最后一个是当前写入文件,可能刚好装下最后几行)。 for _, f := range files { info, err := os.Stat(filepath.Join(dir, f.name)) if err != nil { t.Fatalf("stat %s: %v", f.name, err) } if info.Size() > 120 { t.Fatalf("%s 超过大小上限:%d 字节", f.name, info.Size()) } } } // 序号按数字排序:.10 比 .2 新,清理时不能把新文件当旧的删掉。 func TestRotatorPruneKeepsNewestByIndex(t *testing.T) { dir := t.TempDir() day := time.Now().Format("20060102") for _, name := range []string{ "operations-" + day + ".2.log", "operations-" + day + ".10.log", "operations-" + day + ".1.log", } { if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { t.Fatalf("造日志文件: %v", err) } } w := newRotator(dir, "operations", 1024, 2, nil) w.prune() left := []string{} entries, _ := os.ReadDir(dir) for _, e := range entries { left = append(left, e.Name()) } if len(left) != 2 { t.Fatalf("应保留 2 份,得到 %v", left) } // 最旧的是 .1,必须被删掉。 if strings.Contains(strings.Join(left, ","), ".1.log") { t.Fatalf("最旧的 .1 应被清理,得到 %v", left) } } // 写盘失败只降级、不拖垮服务:控制台仍输出,且告警只发一次。 func TestDegradesToConsoleWhenDirUnwritable(t *testing.T) { // 用一个普通文件顶替日志目录:MkdirAll 必然失败,且对 root 也成立。 blocked := filepath.Join(t.TempDir(), "logs") if err := os.WriteFile(blocked, []byte("not a dir"), 0o644); err != nil { t.Fatalf("造占位文件: %v", err) } logger, console, closeLogs := newTestLogging(t, blocked, slog.LevelInfo, 20, 10) defer closeLogs() for i := 0; i < 5; i++ { logger.Warn("请求被拦截", "reason", "无权访问该资源", ChannelKey, ChannelInterception) } if got := strings.Count(console.String(), "日志文件不可写,已降级为只写控制台"); got != 1 { t.Fatalf("降级告警应只发一次,得到 %d 次:\n%s", got, console.String()) } if got := strings.Count(console.String(), "请求被拦截"); got != 5 { t.Fatalf("降级后拦截日志仍要打控制台,得到 %d 行:\n%s", got, console.String()) } // 进程照常:没有任何 panic / 阻塞,logger 调用全部返回。 logger.Info("操作已放行") } // 日期变化时开新文件(用可注入的时钟直接调 prepare,避免真等到第二天)。 func TestRotatorStartsNewFileOnNewDay(t *testing.T) { dir := t.TempDir() w := newRotator(dir, "operations", 1024, 10, nil) defer func() { _ = w.Close() }() if _, err := w.Write([]byte("day1\n")); err != nil { t.Fatalf("写日志: %v", err) } tomorrow := time.Now().Add(24 * time.Hour) if err := w.prepare(6, tomorrow); err != nil { t.Fatalf("跨天 prepare: %v", err) } if _, err := w.Write([]byte("day2\n")); err != nil { t.Fatalf("写日志: %v", err) } for _, day := range []string{time.Now().Format("20060102"), tomorrow.Format("20060102")} { path := filepath.Join(dir, "operations-"+day+".log") if _, err := os.Stat(path); err != nil { t.Fatalf("跨天后应存在 %s: %v", path, err) } } } // 拦截点里的函数名要短而准:内联后的闭包名会拖一长串调用链,必须收敛。 func TestOriginFuncNameIsShort(t *testing.T) { cases := map[string]string{ "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware.RequireRole.func1": "RequireRole.func1", "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).write.(*server).write.RequireWrite.func1.func4": "RequireWrite.func1", "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).createEvent": "(*server).createEvent", "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.normalizeAvgMode": "normalizeAvgMode", "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).importEntriesWith.func1": "(*server).importEntriesWith.func1", "main.main": "main", } for in, want := range cases { if got := shortFuncName(in); got != want { t.Errorf("shortFuncName(%q) = %q,期望 %q", in, got, want) } } // 模块内的路径裁成「internal/...」相对路径,不带开发机目录。 if sourceRoot != "" { if got := FormatOrigin(sourceRoot+"/internal/httpapi/repo.go", 12, "p.f"); got != "internal/httpapi/repo.go:12 f" { t.Errorf("FormatOrigin 应裁成相对路径,得到 %q", got) } } // 裁不动的路径只留文件名,同样不泄漏绝对路径。 if got := FormatOrigin("/somewhere/else/repo.go", 12, "p.f"); got != "repo.go:12 f" { t.Errorf("裁不动时应只留文件名,得到 %q", got) } }