From 783c9f7d1af743c9f6899de9ac22bd586da4aa7a Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 20 Apr 2026 18:16:30 -0300 Subject: [PATCH] test(soteria): make entrypoint runner testable --- cmd/soteria/main.go | 45 +++++++++--- cmd/soteria/main_test.go | 152 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 cmd/soteria/main_test.go diff --git a/cmd/soteria/main.go b/cmd/soteria/main.go index 972cc41..f0a4de1 100644 --- a/cmd/soteria/main.go +++ b/cmd/soteria/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "log" "net/http" "os/signal" @@ -15,23 +16,46 @@ import ( "scm.bstein.dev/bstein/soteria/internal/server" ) +type applicationServer interface { + Start(context.Context) + Handler() http.Handler +} + +var ( + loadConfigFn = config.Load + newK8sClientFn = k8s.New + newLonghornClient = longhorn.New + newServerFn = func(cfg *config.Config, client *k8s.Client, lh *longhorn.Client) applicationServer { + return server.New(cfg, client, lh) + } + listenAndServeFn = func(s *http.Server) error { return s.ListenAndServe() } + shutdownServerFn = func(s *http.Server, ctx context.Context) error { return s.Shutdown(ctx) } + runApplicationFunc = run +) + func main() { log.SetFlags(log.LstdFlags | log.LUTC) runCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - cfg, err := config.Load() + if err := runApplicationFunc(runCtx); err != nil { + log.Fatalf("%v", err) + } +} + +func run(runCtx context.Context) error { + cfg, err := loadConfigFn() if err != nil { - log.Fatalf("config: %v", err) + return fmt.Errorf("config: %w", err) } - client, err := k8s.New() + client, err := newK8sClientFn() if err != nil { - log.Fatalf("k8s client: %v", err) + return fmt.Errorf("k8s client: %w", err) } - longhornClient := longhorn.New(cfg.LonghornURL) - srv := server.New(cfg, client, longhornClient) + longhornClient := newLonghornClient(cfg.LonghornURL) + srv := newServerFn(cfg, client, longhornClient) srv.Start(runCtx) httpServer := &http.Server{ Addr: cfg.ListenAddr, @@ -45,7 +69,7 @@ func main() { errCh := make(chan error, 1) go func() { log.Printf("soteria listening on %s", cfg.ListenAddr) - errCh <- httpServer.ListenAndServe() + errCh <- listenAndServeFn(httpServer) }() select { @@ -53,14 +77,14 @@ func main() { log.Printf("shutdown signal: %v", runCtx.Err()) case err := <-errCh: if err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("server error: %v", err) + return fmt.Errorf("server error: %w", err) } - return + return nil } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := httpServer.Shutdown(ctx); err != nil { + if err := shutdownServerFn(httpServer, ctx); err != nil { log.Printf("shutdown error: %v", err) } @@ -68,4 +92,5 @@ func main() { if err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("server error: %v", err) } + return nil } diff --git a/cmd/soteria/main_test.go b/cmd/soteria/main_test.go new file mode 100644 index 0000000..8668566 --- /dev/null +++ b/cmd/soteria/main_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "errors" + "net/http" + "testing" + + "scm.bstein.dev/bstein/soteria/internal/config" + "scm.bstein.dev/bstein/soteria/internal/k8s" + "scm.bstein.dev/bstein/soteria/internal/longhorn" +) + +type fakeApplicationServer struct { + started bool + handler http.Handler +} + +func (s *fakeApplicationServer) Start(context.Context) { + s.started = true +} + +func (s *fakeApplicationServer) Handler() http.Handler { + if s.handler == nil { + s.handler = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + } + return s.handler +} + +func TestMainInvokesRunApplicationFunc(t *testing.T) { + originalRun := runApplicationFunc + defer func() { runApplicationFunc = originalRun }() + + called := false + runApplicationFunc = func(context.Context) error { + called = true + return nil + } + + main() + + if !called { + t.Fatalf("expected main to invoke runApplicationFunc") + } +} + +func TestRunReturnsSetupAndServeErrors(t *testing.T) { + restore := swapMainTestHooks() + defer restore() + + cfg := &config.Config{ListenAddr: ":8080", LonghornURL: "http://longhorn.test"} + fakeServer := &fakeApplicationServer{} + + newServerFn = func(cfg *config.Config, client *k8s.Client, lh *longhorn.Client) applicationServer { + return fakeServer + } + newLonghornClient = func(string) *longhorn.Client { return &longhorn.Client{} } + + t.Run("config error", func(t *testing.T) { + loadConfigFn = func() (*config.Config, error) { return nil, errors.New("config exploded") } + if err := run(context.Background()); err == nil || err.Error() != "config: config exploded" { + t.Fatalf("expected config error, got %v", err) + } + }) + + t.Run("k8s error", func(t *testing.T) { + loadConfigFn = func() (*config.Config, error) { return cfg, nil } + newK8sClientFn = func() (*k8s.Client, error) { return nil, errors.New("client exploded") } + if err := run(context.Background()); err == nil || err.Error() != "k8s client: client exploded" { + t.Fatalf("expected k8s client error, got %v", err) + } + }) + + t.Run("listen error", func(t *testing.T) { + loadConfigFn = func() (*config.Config, error) { return cfg, nil } + newK8sClientFn = func() (*k8s.Client, error) { return &k8s.Client{}, nil } + listenAndServeFn = func(*http.Server) error { return errors.New("serve exploded") } + if err := run(context.Background()); err == nil || err.Error() != "server error: serve exploded" { + t.Fatalf("expected serve error, got %v", err) + } + if !fakeServer.started { + t.Fatalf("expected server Start to be invoked before serving") + } + }) +} + +func TestRunHandlesContextCancellationAndServerClosed(t *testing.T) { + restore := swapMainTestHooks() + defer restore() + + cfg := &config.Config{ListenAddr: ":8080", LonghornURL: "http://longhorn.test"} + fakeServer := &fakeApplicationServer{} + listenStarted := make(chan struct{}) + listenReleased := make(chan struct{}) + shutdownCalled := false + + loadConfigFn = func() (*config.Config, error) { return cfg, nil } + newK8sClientFn = func() (*k8s.Client, error) { return &k8s.Client{}, nil } + newLonghornClient = func(string) *longhorn.Client { return &longhorn.Client{} } + newServerFn = func(cfg *config.Config, client *k8s.Client, lh *longhorn.Client) applicationServer { + return fakeServer + } + listenAndServeFn = func(*http.Server) error { + close(listenStarted) + <-listenReleased + return http.ErrServerClosed + } + shutdownServerFn = func(*http.Server, context.Context) error { + shutdownCalled = true + close(listenReleased) + return nil + } + + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- run(runCtx) + }() + + <-listenStarted + cancel() + + if err := <-done; err != nil { + t.Fatalf("expected graceful shutdown, got %v", err) + } + if !fakeServer.started { + t.Fatalf("expected server Start to be called") + } + if !shutdownCalled { + t.Fatalf("expected shutdown hook to be invoked") + } +} + +func swapMainTestHooks() func() { + originalLoad := loadConfigFn + originalK8s := newK8sClientFn + originalLonghorn := newLonghornClient + originalServer := newServerFn + originalListen := listenAndServeFn + originalShutdown := shutdownServerFn + + return func() { + loadConfigFn = originalLoad + newK8sClientFn = originalK8s + newLonghornClient = originalLonghorn + newServerFn = originalServer + listenAndServeFn = originalListen + shutdownServerFn = originalShutdown + } +}