chore: 并入 iotlcm-bot 历史到 bot/(原提交 31dbc1d)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:00 +08:00
36 changed files with 3006 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
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"))
}