148 lines
4.9 KiB
Go
148 lines
4.9 KiB
Go
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"))
|
||
}
|