package main import ( "context" "errors" "fmt" "net/http" "sync" "testing" "time" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/healthz" "git.tswf.io/infra/go-synapse-backupper/pkg/domain" ) type fakeScheduler struct { mu sync.Mutex startCalled bool stopCalled bool stoppedCtx context.Context stopCancel context.CancelFunc } func (f *fakeScheduler) Start() { f.mu.Lock() f.startCalled = true f.mu.Unlock() } func (f *fakeScheduler) Stop() context.Context { f.mu.Lock() f.stopCalled = true f.mu.Unlock() return f.stoppedCtx } func (f *fakeScheduler) wasStarted() bool { f.mu.Lock() defer f.mu.Unlock() return f.startCalled } func (f *fakeScheduler) wasStopped() bool { f.mu.Lock() defer f.mu.Unlock() return f.stopCalled } type exitPanic int func (e exitPanic) Error() string { return fmt.Sprintf("exit %d", int(e)) } func TestRunCmd_Use(t *testing.T) { if runCmd.Use != "run" { t.Errorf("runCmd.Use = %q, want %q", runCmd.Use, "run") } } func TestRunCmd_SIGTERM_stopsScheduler(t *testing.T) { origNewScheduler := newScheduler origOsExit := osExitFunc origRunOnce := runOnceFunc defer func() { newScheduler = origNewScheduler osExitFunc = origOsExit runOnceFunc = origRunOnce }() fake := &fakeScheduler{} fake.stoppedCtx, fake.stopCancel = context.WithCancel(context.Background()) newScheduler = func(expr string, job func()) (domain.Scheduler, error) { return fake, nil } runOnceFunc = func(ctx context.Context, cfg *config.Config) error { return nil } var exitCode int exitPanicked := false osExitFunc = func(code int) { exitCode = code exitPanicked = true panic(exitPanic(code)) } cfg := &config.Config{} cfg.Healthz.Port = 0 cfg.Backup.Cron = "* * * * *" cfg.ShutdownTimeout = 100 * time.Millisecond ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { defer func() { if r := recover(); r != nil { if _, ok := r.(exitPanic); ok { errCh <- nil return } errCh <- fmt.Errorf("unexpected panic: %v", r) return } }() errCh <- runWithConfig(ctx, cfg) }() time.Sleep(50 * time.Millisecond) if !fake.wasStarted() { t.Fatal("scheduler.Start was not called") } cancel() go func() { time.Sleep(50 * time.Millisecond) fake.stopCancel() }() select { case err := <-errCh: if err != nil { t.Fatalf("runWithConfig returned error: %v", err) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for runWithConfig to exit") } if !fake.wasStopped() { t.Error("scheduler.Stop was not called") } if exitPanicked { t.Errorf("unexpected os.Exit call with code %d", exitCode) } } func TestRunCmd_SIGTERM_forcesExitAfterTimeout(t *testing.T) { origNewScheduler := newScheduler origOsExit := osExitFunc origNewHealthz := newHealthz origRunOnce := runOnceFunc defer func() { newScheduler = origNewScheduler osExitFunc = origOsExit newHealthz = origNewHealthz runOnceFunc = origRunOnce }() fake := &fakeScheduler{} var cancelFunc context.CancelFunc fake.stoppedCtx, cancelFunc = context.WithCancel(context.Background()) defer cancelFunc() newScheduler = func(expr string, job func()) (domain.Scheduler, error) { return fake, nil } newHealthz = func(port int) (healthz.Server, error) { return healthz.New(port) } runOnceFunc = func(ctx context.Context, cfg *config.Config) error { return nil } var exitCode int exitPanicked := false osExitFunc = func(code int) { exitCode = code exitPanicked = true panic(exitPanic(code)) } cfg := &config.Config{} cfg.Healthz.Port = 0 cfg.Backup.Cron = "* * * * * *" cfg.ShutdownTimeout = 100 * time.Millisecond ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { defer func() { if r := recover(); r != nil { if _, ok := r.(exitPanic); ok { errCh <- nil return } errCh <- fmt.Errorf("unexpected panic: %v", r) return } }() errCh <- runWithConfig(ctx, cfg) }() time.Sleep(50 * time.Millisecond) cancel() select { case err := <-errCh: if err != nil { t.Fatalf("runWithConfig returned error: %v", err) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for runWithConfig to exit") } if !fake.wasStopped() { t.Error("scheduler.Stop was not called") } if !exitPanicked { t.Fatal("expected os.Exit to be called") } if exitCode != 0 { t.Errorf("os.Exit code = %d, want 0", exitCode) } } func TestRunCmd_Healthz503DuringShutdown(t *testing.T) { origNewScheduler := newScheduler origOsExit := osExitFunc origNewHealthz := newHealthz origRunOnce := runOnceFunc defer func() { newScheduler = origNewScheduler osExitFunc = origOsExit newHealthz = origNewHealthz runOnceFunc = origRunOnce }() fake := &fakeScheduler{} fake.stoppedCtx, fake.stopCancel = context.WithCancel(context.Background()) newScheduler = func(expr string, job func()) (domain.Scheduler, error) { return fake, nil } srv, err := healthz.New(0) if err != nil { t.Fatalf("failed to create healthz server: %v", err) } newHealthz = func(port int) (healthz.Server, error) { return srv, nil } runOnceFunc = func(ctx context.Context, cfg *config.Config) error { return nil } osExitFunc = func(code int) { panic(exitPanic(code)) } cfg := &config.Config{} cfg.Healthz.Port = 0 cfg.Backup.Cron = "* * * * * *" cfg.ShutdownTimeout = 5 * time.Second ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { defer func() { if r := recover(); r != nil { if _, ok := r.(exitPanic); ok { errCh <- nil return } errCh <- fmt.Errorf("unexpected panic: %v", r) return } }() errCh <- runWithConfig(ctx, cfg) }() time.Sleep(100 * time.Millisecond) addr := srv.Addr() resp, err := http.Get("http://" + addr + "/healthz") if err != nil { t.Fatalf("healthz request failed: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("healthz status before shutdown = %d, want %d", resp.StatusCode, http.StatusOK) } cancel() var status503 bool for iteration := 0; iteration < 20; iteration++ { resp, err = http.Get("http://" + addr + "/healthz") if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusServiceUnavailable { status503 = true break } } time.Sleep(10 * time.Millisecond) } if !status503 { t.Error("did not observe /healthz returning 503 during shutdown") } fake.stopCancel() select { case err := <-errCh: if err != nil { t.Fatalf("runWithConfig returned error: %v", err) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for runWithConfig to exit") } } func TestRunCmd_HealthzBindFailure(t *testing.T) { origNewScheduler := newScheduler origNewHealthz := newHealthz origOsExit := osExitFunc origRunOnce := runOnceFunc defer func() { newScheduler = origNewScheduler newHealthz = origNewHealthz osExitFunc = origOsExit runOnceFunc = origRunOnce }() fake := &fakeScheduler{} var cancelFunc context.CancelFunc fake.stoppedCtx, cancelFunc = context.WithCancel(context.Background()) defer cancelFunc() newScheduler = func(expr string, job func()) (domain.Scheduler, error) { return fake, nil } newHealthz = func(port int) (healthz.Server, error) { return nil, errors.New("bind failed") } runOnceFunc = func(ctx context.Context, cfg *config.Config) error { return nil } var exitCode int exitPanicked := false osExitFunc = func(code int) { exitCode = code exitPanicked = true panic(exitPanic(code)) } cfg := &config.Config{} cfg.Backup.Cron = "* * * * * *" errCh := make(chan error, 1) go func() { defer func() { if r := recover(); r != nil { if _, ok := r.(exitPanic); ok { errCh <- nil return } errCh <- fmt.Errorf("unexpected panic: %v", r) return } }() errCh <- runWithConfig(context.Background(), cfg) }() select { case err := <-errCh: if err != nil { t.Fatalf("runWithConfig returned error: %v", err) } case <-time.After(2 * time.Second): t.Fatal("timeout waiting for runWithConfig to exit") } if !exitPanicked { t.Fatal("expected os.Exit to be called on healthz bind failure") } if exitCode != 1 { t.Errorf("os.Exit code = %d, want 1", exitCode) } }