Files
Crussell/backend/internal/jobs/scheduler_test.go
T
popertots 8720e28dbe Add webhook-event retention job, schema, and testdb pre-flight hint
square_webhook_events is now CREATE TABLE IF NOT EXISTS, registered as the 24th maintenance job (sweep-square-webhook-events, daily 2:30am) pruning rows older than 90 days, and testdb gives a clear docker compose hint when the admin DB connection fails.
2026-08-22 00:34:49 +01:00

589 lines
14 KiB
Go

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 != 24 {
t.Fatalf("RegisterAll() registered %d jobs, want 24", 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,
"sweep-stale-pending-payments": true,
"sweep-stale-terminal-checkouts": 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,
"sweep-square-webhook-events": 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)
}