package jobs import ( "context" "errors" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" ) // TestNew verifies New returns a functional Scheduler. func TestNew(t *testing.T) { s := New() if s == nil { t.Fatal("New() returned nil") } if s.cron == nil { t.Error("Scheduler.cron is nil") } if s.baseCtx == nil { t.Error("Scheduler.baseCtx is nil") } if s.cancel == nil { t.Error("Scheduler.cancel is nil") } } // TestRegister_ValidCron accepts standard 5-field cron expressions. func TestRegister_ValidCron(t *testing.T) { exprs := []string{ "*/5 * * * *", "0 * * * *", "0 3 * * *", "30 3 * * *", "* * * * *", "0 0 1 1 *", } for _, expr := range exprs { t.Run(expr, func(t *testing.T) { s := New() s.Register(Job{ Name: "test", Schedule: expr, Handler: func(ctx context.Context) (int, error) { return 0, nil }, }) if len(s.registry) != 1 { t.Errorf("expected 1 job, got %d", len(s.registry)) } }) } } // TestRegister_InvalidCron panics on garbage expressions. func TestRegister_InvalidCron(t *testing.T) { exprs := []string{ "not-a-cron", "", "* * * *", "70 * * * *", } for _, expr := range exprs { t.Run(expr, func(t *testing.T) { s := New() defer func() { if r := recover(); r == nil { t.Error("expected panic for invalid cron expression") } }() s.Register(Job{ Name: "bad", Schedule: expr, Handler: func(ctx context.Context) (int, error) { return 0, nil }, }) }) } } // TestWrapJob_RunsHandler verifies the wrapped function calls the handler. func TestWrapJob_RunsHandler(t *testing.T) { s := New() var ran bool job := Job{ Name: "test-run", Handler: func(ctx context.Context) (int, error) { ran = true return 0, nil }, } fn := s.wrapJob(job) fn() if !ran { t.Error("handler was not called") } } // TestWrapJob_PanicRecovery catches panics in the handler. func TestWrapJob_PanicRecovery(t *testing.T) { s := New() job := Job{ Name: "test-panic", Handler: func(ctx context.Context) (int, error) { panic("deliberate panic") }, } fn := s.wrapJob(job) // Should not propagate the panic fn() } // TestWrapJob_TimeoutCancelsContext verifies the handler receives a deadline. func TestWrapJob_TimeoutCancelsContext(t *testing.T) { s := New() ctxReceived := make(chan context.Context, 1) job := Job{ Name: "test-timeout", Timeout: 10 * time.Millisecond, Handler: func(ctx context.Context) (int, error) { ctxReceived <- ctx // Block until context is cancelled <-ctx.Done() return 0, ctx.Err() }, } fn := s.wrapJob(job) done := make(chan error, 1) go func() { fn() done <- nil }() select { case ctx := <-ctxReceived: deadline, ok := ctx.Deadline() if !ok { t.Error("expected a deadline on the context") } if time.Until(deadline) > 10*time.Millisecond { t.Errorf("deadline too far in the future: %v", time.Until(deadline)) } case <-time.After(time.Second): t.Fatal("handler was not called within 1s") } select { case <-done: // Finished case <-time.After(time.Second): t.Fatal("job did not complete within 1s") } } // TestWrapJob_ConcurrencySkip skips the second invocation when one is inflight. func TestWrapJob_ConcurrencySkip(t *testing.T) { s := New() var mu sync.Mutex callCount := 0 block := make(chan struct{}) job := Job{ Name: "test-concurrency", Concurrency: 1, Handler: func(ctx context.Context) (int, error) { mu.Lock() callCount++ mu.Unlock() <-block // Block until test releases return 0, nil }, } fn := s.wrapJob(job) // First invocation — will block inside handler go fn() // Wait for first invocation to enter handler assert.Eventually(t, func() bool { mu.Lock() defer mu.Unlock() return callCount == 1 }, time.Second, 10*time.Millisecond, "expected first invocation to start") // Second invocation — should skip because first is still running fn() mu.Lock() if callCount > 1 { t.Errorf("expected callCount 1 (skip), got %d", callCount) } mu.Unlock() // Clean up close(block) } // TestWrapJob_ConcurrencyNoLimit allows multiple invocations. func TestWrapJob_ConcurrencyNoLimit(t *testing.T) { s := New() block := make(chan struct{}) var mu sync.Mutex callCount := 0 started := make(chan struct{}, 2) job := Job{ Name: "test-no-limit", Concurrency: 0, // unlimited Handler: func(ctx context.Context) (int, error) { mu.Lock() callCount++ mu.Unlock() started <- struct{}{} <-block return 0, nil }, } fn := s.wrapJob(job) go fn() go fn() // Both should start <-started <-started mu.Lock() if callCount != 2 { t.Errorf("expected callCount 2, got %d", callCount) } mu.Unlock() close(block) } // TestStartAndShutdown verifies the scheduler lifecycle. func TestStartAndShutdown(t *testing.T) { s := New() s.Register(Job{ Name: "test-lifecycle", Schedule: "0 0 1 1 *", // Once a year — won't fire during test Handler: func(ctx context.Context) (int, error) { return 0, nil }, }) s.Start() select { case <-s.Shutdown(): // Clean shutdown case <-time.After(time.Second): t.Fatal("shutdown did not complete within 1s") } } // TestShutdown_CancelsBaseContext verifies in-flight jobs receive cancellation. func TestShutdown_CancelsBaseContext(t *testing.T) { s := New() ctxReceived := make(chan context.Context, 1) block := make(chan struct{}) s.Register(Job{ Name: "test-shutdown-cancel", Schedule: "* * * * *", // Will fire within 60s — but we test wrapJob directly Handler: func(ctx context.Context) (int, error) { return 0, nil }, }) // Test that the base context is cancelled on shutdown job := Job{ Name: "test-cancel", Handler: func(ctx context.Context) (int, error) { ctxReceived <- ctx <-block return 0, nil }, } fn := s.wrapJob(job) go fn() select { case <-ctxReceived: // Handler started case <-time.After(time.Second): t.Fatal("handler was not called within 1s") } // Shutdown should cancel the base context shutdownDone := make(chan struct{}) go func() { <-s.Shutdown() close(shutdownDone) }() // The handler's context should be cancelled now select { case <-shutdownDone: // Good case <-time.After(time.Second): t.Fatal("shutdown did not complete within 1s") } close(block) } // ============================================================ // RandomPrefix / coloredDuration / coloredRows Tests // ============================================================ func TestRandomPrefix_NonEmpty(t *testing.T) { p := RandomPrefix() if p == "" { t.Error("expected RandomPrefix to return non-empty string") } if len(p) < 8 { t.Errorf("expected RandomPrefix to be at least 8 chars, got %q (len=%d)", p, len(p)) } } func TestRandomPrefix_StableDuringRun(t *testing.T) { a := RandomPrefix() b := RandomPrefix() if a != b { t.Errorf("expected RandomPrefix to be stable, got %q != %q", a, b) } } func TestColoredDuration_Fast(t *testing.T) { out := coloredDuration(100 * time.Millisecond) if !strings.Contains(out, "100ms") { t.Errorf("expected 100ms in output, got %q", out) } if !strings.Contains(out, "[32m") { t.Errorf("expected green color (32) for fast duration, got %q", out) } if !strings.HasSuffix(out, "[0m") { t.Error("expected output to end with color reset") } } func TestColoredDuration_Medium(t *testing.T) { out := coloredDuration(2 * time.Second) if !strings.Contains(out, "2s") { t.Errorf("expected 2s in output, got %q", out) } if !strings.Contains(out, "[33m") { t.Errorf("expected yellow color (33) for medium duration, got %q", out) } if !strings.HasSuffix(out, "[0m") { t.Error("expected output to end with color reset") } } func TestColoredDuration_Slow(t *testing.T) { out := coloredDuration(10 * time.Second) if !strings.Contains(out, "10s") { t.Errorf("expected 10s in output, got %q", out) } if !strings.Contains(out, "[31m") { t.Errorf("expected red color (31) for slow duration, got %q", out) } if !strings.HasSuffix(out, "[0m") { t.Error("expected output to end with color reset") } } func TestColoredRows_Singular(t *testing.T) { out := coloredRows(1) if !strings.Contains(out, "1 row") { t.Errorf("expected '1 row' in output, got %q", out) } if !strings.Contains(out, "[34;1m") { t.Errorf("expected bold blue (34;1) color, got %q", out) } if !strings.HasSuffix(out, "[0m") { t.Error("expected output to end with color reset") } } func TestColoredRows_Plural(t *testing.T) { out := coloredRows(3) if !strings.Contains(out, "3 rows") { t.Errorf("expected '3 rows' in output, got %q", out) } if !strings.Contains(out, "[34;1m") { t.Errorf("expected bold blue (34;1) color, got %q", out) } } func TestColoredRows_Zero(t *testing.T) { out := coloredRows(0) if !strings.Contains(out, "0 rows") { t.Errorf("expected '0 rows' in output, got %q", out) } } func TestColoredRows_Reset(t *testing.T) { if !strings.HasSuffix(coloredRows(0), "[0m") { t.Error("expected output to end with color reset") } if !strings.HasSuffix(coloredRows(5), "[0m") { t.Error("expected output to end with color reset") } } // ============================================================ // RegisterAll Tests // ============================================================ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) { t.Parallel() s := New() RegisterAll(s) if got := len(s.registry); got != 21 { t.Fatalf("RegisterAll() registered %d jobs, want 21", got) } registered := make(map[string]Job, len(s.registry)) for _, j := range s.registry { registered[j.Name] = j } expected := expectedJobNames() for name := range expected { job, ok := registered[name] if !ok { t.Errorf("missing expected job %q", name) continue } requireValidJob(t, job) } for name := range registered { if !expected[name] { t.Errorf("unexpected job %q", name) } } } // TestRegisterAll_ValidSchedules verifies all cron expressions in registered jobs // parse without panic. RegisterAll internally calls Register, which parses every // schedule; if this test completes without panic, all 21 schedules are valid. func TestRegisterAll_ValidSchedules(t *testing.T) { t.Parallel() s := New() // RegisterAll panics on invalid cron — no panic means all schedules are valid. RegisterAll(s) } // TestRegisterAll_HandlerSignatures verifies every registered job has a non-nil // handler function, confirming all handlers are properly wired at compile time. func TestRegisterAll_HandlerSignatures(t *testing.T) { t.Parallel() s := New() RegisterAll(s) for _, j := range s.registry { if j.Handler == nil { t.Errorf("job %q has nil Handler", j.Name) } } } func expectedJobNames() map[string]bool { return map[string]bool{ "cleanup-reservations": true, "cleanup-expired-deposits": true, "cleanup-rate-limiters": true, "cleanup-gdpr-export-cache": true, "sweep-pending-square-refunds": true, "cleanup-progressive-rate-limiter": true, "cleanup-expired-loyalty-redemptions": true, "cleanup-old-idempotency-keys": true, "cleanup-revoked-jtis": true, "cleanup-stale-login-entries": true, "anonymize-stale-guest-accounts": true, "cleanup-expired-financial-records": true, "cleanup-idle-accounts": true, "cleanup-expired-gift-cards": true, "cleanup-old-name-history": true, "notify-unpaid-1-week": true, "notify-unpaid-1-month": true, "transition-discount-campaigns": true, "cleanup-verification-codes": true, "cleanup-refresh-tokens": true, "apply-default-hours": true, } } // TestRegisterAll_NoDuplicateCronExpressions verifies that no two jobs share the // exact same cron expression. Identical schedules risk DB contention and should // be deliberately staggered. func TestRegisterAll_NoDuplicateCronExpressions(t *testing.T) { t.Parallel() s := New() RegisterAll(s) cronJobs := make(map[string][]string) for _, j := range s.registry { cronJobs[j.Schedule] = append(cronJobs[j.Schedule], j.Name) } // Known-intentional groupings: jobs at the same frequency that touch // disjoint tables (no contention risk). knownGroupings := map[int]bool{ 5: true, // */5 * * * * — 5 cleanup jobs (incl. sweep-pending-square-refunds), different domains // 0 * * * * — 5 hourly cleanup jobs, different tables 2: true, // 0 2 * * * — 2 daily cleanup jobs, different tables } for cron, jobs := range cronJobs { if len(jobs) > 1 && !knownGroupings[len(jobs)] { t.Errorf("cron %q shared by %d jobs: %v — stagger to avoid contention", cron, len(jobs), jobs) } } } func requireValidJob(t *testing.T, j Job) { t.Helper() if j.Name == "" { t.Error("job has empty Name") } if j.Schedule == "" { t.Errorf("job %q has empty Schedule", j.Name) } if j.Handler == nil { t.Errorf("job %q has nil Handler", j.Name) } if j.Timeout <= 0 { t.Errorf("job %q has non-positive Timeout (%v)", j.Name, j.Timeout) } } // ============================================================ // Hostname / initHostname Tests // ============================================================ // TestHostname_NonEmpty verifies Hostname() returns a non-empty string // after package init, covering the Hostname() accessor function. func TestHostname_NonEmpty(t *testing.T) { t.Parallel() assert.NotEmpty(t, Hostname(), "Hostname() should return a non-empty string") } // TestInitHostname_HappyPath verifies initHostname assigns the value from // the hostname resolver when it returns a valid name. func TestInitHostname_HappyPath(t *testing.T) { orig := hostname defer func() { hostname = orig }() initHostname(func() (string, error) { return "my-host", nil }) assert.Equal(t, "my-host", hostname) } // TestInitHostname_FallbackOnError verifies initHostname falls back to // "localhost" when the resolver returns an error. func TestInitHostname_FallbackOnError(t *testing.T) { orig := hostname defer func() { hostname = orig }() initHostname(func() (string, error) { return "", errors.New("hostname unavailable") }) assert.Equal(t, "localhost", hostname) } // TestInitHostname_FallbackOnEmpty verifies initHostname falls back to // "localhost" when the resolver returns an empty string (no error). func TestInitHostname_FallbackOnEmpty(t *testing.T) { orig := hostname defer func() { hostname = orig }() initHostname(func() (string, error) { return "", nil }) assert.Equal(t, "localhost", hostname) }