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.
589 lines
14 KiB
Go
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)
|
|
}
|