169 lines
5.5 KiB
Go
169 lines
5.5 KiB
Go
package cron
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func mustPanicM13(t *testing.T, name string, fn func()) {
|
||
t.Helper()
|
||
defer func() {
|
||
if recover() == nil {
|
||
t.Fatalf("%s should panic", name)
|
||
}
|
||
}()
|
||
fn()
|
||
}
|
||
|
||
// TestCronRunTaskPanicRecovered_M13 回归:RunTask handler panic 时,executeTask 边界
|
||
// recover 转为 error 返回调用方并记入 LastError,不崩进程;running 守卫随后释放。
|
||
// 修复前:panic 直接上抛 → 测试进程崩溃。
|
||
func TestCronRunTaskPanicRecovered_M13(t *testing.T) {
|
||
s := NewScheduler()
|
||
s.AddTask("panic", Every(time.Minute), func(context.Context) error { panic("boom") })
|
||
|
||
err := s.RunTask("panic")
|
||
if err == nil || !strings.Contains(err.Error(), "panic recovered") {
|
||
t.Fatalf("RunTask err = %v, want a panic-recovered error", err)
|
||
}
|
||
|
||
got, _ := s.GetTask("panic")
|
||
if got.LastError == nil || !strings.Contains(got.LastError.Error(), "panic recovered") {
|
||
t.Fatalf("LastError = %v, want panic-recovered error", got.LastError)
|
||
}
|
||
|
||
// running 守卫必须已释放:再次 RunTask 不应返“任务正在执行中”(会再次 panic→recover)。
|
||
err2 := s.RunTask("panic")
|
||
if err2 != nil && strings.Contains(err2.Error(), "任务正在执行中") {
|
||
t.Fatal("running guard not released after panic (second RunTask blocked)")
|
||
}
|
||
}
|
||
|
||
// TestCronRunTaskRecordsLastError_M13 回归:RunTask 手动路径此前只更 LastRun/RunCount,
|
||
// 不记 LastError。修复后与调度路径一致,正常 error 也记入 LastError。
|
||
func TestCronRunTaskRecordsLastError_M13(t *testing.T) {
|
||
s := NewScheduler()
|
||
sentinel := errors.New("sentinel fail")
|
||
s.AddTask("err", Every(time.Minute), func(context.Context) error { return sentinel })
|
||
|
||
err := s.RunTask("err")
|
||
if !errors.Is(err, sentinel) {
|
||
t.Fatalf("RunTask err = %v, want sentinel", err)
|
||
}
|
||
got, _ := s.GetTask("err")
|
||
if !errors.Is(got.LastError, sentinel) {
|
||
t.Fatalf("LastError = %v, want sentinel", got.LastError)
|
||
}
|
||
if got.RunCount != 1 {
|
||
t.Fatalf("RunCount = %d, want 1", got.RunCount)
|
||
}
|
||
}
|
||
|
||
// TestCronCheckAndRunPanicRecovered_M13 回归:调度 goroutine 内 handler panic 时,
|
||
// recover 防止进程崩溃;同 tick 派生的兄弟 goroutine 不受影响;无 goroutine 泄漏。
|
||
// 同包测试直接驱动 checkAndRun + wg.Wait,零 sleep、确定性。
|
||
// 修复前:派生 goroutine 内未 recover 的 panic 终止测试进程。
|
||
func TestCronCheckAndRunPanicRecovered_M13(t *testing.T) {
|
||
s := NewScheduler()
|
||
var ran atomic.Int32
|
||
s.AddTask("panic", Every(time.Millisecond), func(context.Context) error { panic("boom") })
|
||
s.AddTask("canary", Every(time.Millisecond), func(context.Context) error {
|
||
ran.Add(1)
|
||
return nil
|
||
})
|
||
|
||
// AddTask 设 NextRun=now+1ms;手动改到过去确保两者 due,跨过 1s ticker 的不确定性。
|
||
s.mu.Lock()
|
||
s.tasks["panic"].NextRun = time.Now().Add(-time.Second)
|
||
s.tasks["canary"].NextRun = time.Now().Add(-time.Second)
|
||
s.mu.Unlock()
|
||
|
||
s.checkAndRun()
|
||
s.wg.Wait() // 等派生的两个 goroutine 退出,证无泄漏
|
||
|
||
pt, _ := s.GetTask("panic")
|
||
if pt.LastError == nil || !strings.Contains(pt.LastError.Error(), "panic recovered") {
|
||
t.Fatalf("panic task LastError = %v, want panic-recovered error", pt.LastError)
|
||
}
|
||
if ran.Load() == 0 {
|
||
t.Fatal("canary did not run — sibling goroutine killed by panic?")
|
||
}
|
||
}
|
||
|
||
func TestCronAddTaskRejectsNilScheduleOrHandler_M13(t *testing.T) {
|
||
s := NewScheduler()
|
||
mustPanicM13(t, "nil schedule", func() {
|
||
s.AddTask("nil-schedule", nil, func(context.Context) error { return nil })
|
||
})
|
||
mustPanicM13(t, "nil handler", func() {
|
||
s.AddTask("nil-handler", Every(time.Minute), nil)
|
||
})
|
||
}
|
||
|
||
func TestCronScheduleConstructorsRejectInvalidBounds_M13(t *testing.T) {
|
||
mustPanicM13(t, "Every(0)", func() { Every(0) })
|
||
mustPanicM13(t, "Every(negative)", func() { Every(-time.Second) })
|
||
mustPanicM13(t, "Daily hour", func() { Daily(24, 0) })
|
||
mustPanicM13(t, "Daily minute", func() { Daily(23, 60) })
|
||
mustPanicM13(t, "Weekly day", func() { Weekly(time.Weekday(9), 9, 0) })
|
||
mustPanicM13(t, "Weekly hour", func() { Weekly(time.Monday, -1, 0) })
|
||
mustPanicM13(t, "Weekly minute", func() { Weekly(time.Monday, 9, -1) })
|
||
}
|
||
|
||
func TestCronAddTaskRejectsInvalidExportedScheduleValues_M13(t *testing.T) {
|
||
s := NewScheduler()
|
||
mustPanicM13(t, "invalid interval schedule", func() {
|
||
s.AddTask("bad-interval", &IntervalSchedule{}, func(context.Context) error { return nil })
|
||
})
|
||
mustPanicM13(t, "invalid daily schedule", func() {
|
||
s.AddTask("bad-daily", &DailySchedule{Hour: 99}, func(context.Context) error { return nil })
|
||
})
|
||
mustPanicM13(t, "invalid weekly schedule", func() {
|
||
s.AddTask("bad-weekly", &WeeklySchedule{Day: time.Weekday(8)}, func(context.Context) error { return nil })
|
||
})
|
||
}
|
||
|
||
func TestCronStartAfterStopRebuildsContext_M13(t *testing.T) {
|
||
s := NewScheduler()
|
||
s.AddTask("ctx", Every(time.Hour), func(ctx context.Context) error {
|
||
return ctx.Err()
|
||
})
|
||
|
||
s.Start()
|
||
s.Stop()
|
||
s.Start()
|
||
t.Cleanup(s.Stop)
|
||
|
||
if err := s.RunTask("ctx"); err != nil {
|
||
t.Fatalf("RunTask after Stop/Start got ctx err = %v, want nil", err)
|
||
}
|
||
}
|
||
|
||
func TestCronCheckAndRunSkipsAfterStopContextCanceled_M13(t *testing.T) {
|
||
s := NewScheduler()
|
||
var ran atomic.Int32
|
||
s.AddTask("due", Every(time.Minute), func(context.Context) error {
|
||
ran.Add(1)
|
||
return nil
|
||
})
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel()
|
||
|
||
s.mu.Lock()
|
||
s.running = false
|
||
s.tasks["due"].NextRun = time.Now().Add(-time.Second)
|
||
s.mu.Unlock()
|
||
|
||
s.checkAndRun(ctx)
|
||
s.wg.Wait()
|
||
|
||
if got := ran.Load(); got != 0 {
|
||
t.Fatalf("task ran %d times after stopped/canceled ctx, want 0", got)
|
||
}
|
||
}
|