package notify import ( "context" "errors" "log/slog" "os" "strings" "sync" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-bot/internal/state" ) // captureHandler 收集所有日志记录,用于断言重报次数。 type captureHandler struct { mu sync.Mutex records []slog.Record } func (h *captureHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { h.mu.Lock() defer h.mu.Unlock() h.records = append(h.records, r) return nil } func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } func (h *captureHandler) count() int { h.mu.Lock() defer h.mu.Unlock() return len(h.records) } func (h *captureHandler) lastMessage() string { h.mu.Lock() defer h.mu.Unlock() if len(h.records) == 0 { return "" } return h.records[len(h.records)-1].Message } func newCaptureLogger() (*slog.Logger, *captureHandler) { h := &captureHandler{} return slog.New(h), h } // TestTransientWarnThrottlesRepeatedErrors 验证同一个归档的临时错误 // 不会每轮都刷一条 ERROR:前几次可见,之后进入退避,超过上限后静默。 // // 背景:2026-10-21/22 bot 被移出飞书群 10 小时,230002 错误刷了 18,266 条, // 占日志 99.9%,真正的同步结果通知被完全淹没。 func TestTransientWarnThrottlesRepeatedErrors(t *testing.T) { logger, h := newCaptureLogger() svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger) transient := errors.New("feishu send failed: http=400 code=230002 msg=Bot/User can NOT be out of the chat.") // 模拟 500 轮扫描都失败(约等于 8 分钟的 1 秒轮询)。 for i := 0; i < 500; i++ { svc.warnOnce("/data/full-sync/run-abc.json", transient) } got := h.count() // 前 5 次必报,之后受退避限制。500 轮里退避间隔从 1 分钟起翻倍, // 最多也就多出个位数。这里给一个宽松上界,重点是不能是 500。 if got > transientWarnMaxCount+2 { t.Fatalf("logged %d times for 500 failures, want <= %d (throttle is not working)", got, transientWarnMaxCount+2) } if got < transientWarnFastPhase { t.Fatalf("logged %d times, want at least %d (fast phase should always report)", got, transientWarnFastPhase) } } // TestTransientWarnDifferentArchivesAreIndependent 验证节流按归档文件隔离, // 一个坏文件不能掩盖另一个文件的错误。 func TestTransientWarnDifferentArchivesAreIndependent(t *testing.T) { logger, h := newCaptureLogger() svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger) errA := errors.New("boom-a") for i := 0; i < 20; i++ { svc.warnOnce("/data/full-sync/run-a.json", errA) } afterA := h.count() // 另一个文件应该独立计数,立刻能看到自己的错误。 svc.warnOnce("/data/full-sync/run-b.json", errors.New("boom-b")) if h.count() != afterA+1 { t.Fatalf("second archive was throttled by the first: before=%d after=%d", afterA, h.count()) } } // TestPermanentWarnOnlyOnce 验证永久错误仍然只报一次。 func TestPermanentWarnOnlyOnce(t *testing.T) { logger, h := newCaptureLogger() svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger) permanent := permanentErr(errors.New("archive rounds is empty")) for i := 0; i < 50; i++ { svc.warnOnce("/data/full-sync/run-bad.json", permanent) } if got := h.count(); got != 1 { t.Fatalf("permanent error logged %d times, want exactly 1", got) } } // TestTransientWarnSuppressionMessage 验证超过上限时给出的汇总日志包含排查提示, // 让运维一眼看出这是配置问题而不是代码 bug。 func TestTransientWarnSuppressionMessage(t *testing.T) { logger, h := newCaptureLogger() svc := New(t.TempDir(), "test", time.Second, state.New(t.TempDir()), nil, logger) transient := errors.New("code=230002 Bot/User can NOT be out of the chat.") // 直接构造已进入退避阶段的状态,避免测试里真的等几分钟。 key := skipKey("/data/full-sync/run-abc.json") svc.transient[key] = &transientWarn{ count: transientWarnMaxCount, lastLog: time.Now().Add(-time.Hour), } svc.warnOnce("/data/full-sync/run-abc.json", transient) msg := h.lastMessage() if !strings.Contains(msg, "suppressing") { t.Fatalf("suppression message = %q, want it to mention suppressing", msg) } } // TestTransientWarnZeroValueService 验证未初始化的 Service(transient 为 nil) // 不会 panic——生产上 Service 由 New 构造,但零值也应安全。 func TestTransientWarnZeroValueService(t *testing.T) { svc := Service{logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 10}))} defer func() { if r := recover(); r != nil { t.Fatalf("zero-value Service panicked: %v", r) } }() svc.warnOnce("/tmp/run-x.json", errors.New("transient")) }