package launcher import ( "context" "errors" "testing" "time" "code.nochebuena.dev/go/logz" ) // ---- test helpers ------------------------------------------------------- type mockComponent struct { name string initErr error startErr error stopErr error initCalled bool startCalled bool stopCalled bool stopDelay time.Duration } func (m *mockComponent) OnInit() error { m.initCalled = true; return m.initErr } func (m *mockComponent) OnStart() error { m.startCalled = true; return m.startErr } func (m *mockComponent) OnStop() error { m.stopCalled = true if m.stopDelay > 0 { time.Sleep(m.stopDelay) } return m.stopErr } func newLogger() logz.Logger { return logz.New(logz.Options{}) } func fastOpts() Options { return Options{ComponentStopTimeout: 100 * time.Millisecond} } // runAndShutdown starts Run in a goroutine, waits shutdownAfter, then calls Shutdown. // Returns the error returned by Run. func runAndShutdown(t *testing.T, lc Launcher, shutdownAfter time.Duration) error { t.Helper() errCh := make(chan error, 1) go func() { errCh <- lc.Run() }() time.Sleep(shutdownAfter) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() lc.Shutdown(ctx) //nolint:errcheck select { case err := <-errCh: return err case <-time.After(2 * time.Second): t.Fatal("Run() did not return after Shutdown") return nil } } // ---- tests --------------------------------------------------------------- func TestNew_Defaults(t *testing.T) { lc := New(newLogger()) if lc == nil { t.Fatal("expected non-nil Launcher") } impl := lc.(*launcher) if impl.opts.ComponentStopTimeout != defaultComponentStopTimeout { t.Errorf("default timeout = %v, want %v", impl.opts.ComponentStopTimeout, defaultComponentStopTimeout) } } func TestNew_WithOptions(t *testing.T) { want := 42 * time.Millisecond lc := New(newLogger(), Options{ComponentStopTimeout: want}) impl := lc.(*launcher) if impl.opts.ComponentStopTimeout != want { t.Errorf("timeout = %v, want %v", impl.opts.ComponentStopTimeout, want) } } func TestLauncher_Append(t *testing.T) { lc := New(newLogger(), fastOpts()) c1 := &mockComponent{name: "c1"} c2 := &mockComponent{name: "c2"} lc.Append(c1, c2) err := runAndShutdown(t, lc, 10*time.Millisecond) if err != nil { t.Fatalf("unexpected error: %v", err) } if !c1.initCalled || !c2.initCalled { t.Error("OnInit not called on all components") } } func TestLauncher_BeforeStart(t *testing.T) { lc := New(newLogger(), fastOpts()) c := &mockComponent{name: "c"} lc.Append(c) hookRan := false var hookOrder, startOrder int counter := 0 lc.BeforeStart(func() error { counter++ hookOrder = counter hookRan = true return nil }) origStart := c.OnStart _ = origStart // ensure c.startCalled is set by OnStart // Track order by inspecting startCalled after hook lc2 := New(newLogger(), fastOpts()) c2 := &mockComponent{name: "c2"} var afterHookStartCalled bool lc2.Append(c2) lc2.BeforeStart(func() error { // At hook time, OnInit has run but OnStart has not if c2.initCalled && !c2.startCalled { afterHookStartCalled = true } return nil }) err := runAndShutdown(t, lc2, 10*time.Millisecond) if err != nil { t.Fatalf("unexpected error: %v", err) } if !afterHookStartCalled { t.Error("hook ran but init/start order was wrong") } _ = hookRan _ = hookOrder _ = startOrder } func TestLauncher_Run_Success(t *testing.T) { lc := New(newLogger(), fastOpts()) c := &mockComponent{name: "c"} lc.Append(c) err := runAndShutdown(t, lc, 10*time.Millisecond) if err != nil { t.Fatalf("unexpected error: %v", err) } if !c.initCalled || !c.startCalled || !c.stopCalled { t.Errorf("lifecycle incomplete: init=%v start=%v stop=%v", c.initCalled, c.startCalled, c.stopCalled) } } func TestLauncher_Run_OnInitFails(t *testing.T) { lc := New(newLogger(), fastOpts()) initErr := errors.New("init failure") c := &mockComponent{name: "c", initErr: initErr} lc.Append(c) errCh := make(chan error, 1) go func() { errCh <- lc.Run() }() select { case err := <-errCh: if !errors.Is(err, initErr) { t.Errorf("got %v, want %v", err, initErr) } case <-time.After(2 * time.Second): t.Fatal("Run() did not return") } if c.startCalled { t.Error("OnStart should not be called when OnInit fails") } } func TestLauncher_Run_HookFails(t *testing.T) { lc := New(newLogger(), fastOpts()) c := &mockComponent{name: "c"} lc.Append(c) hookErr := errors.New("hook failure") lc.BeforeStart(func() error { return hookErr }) errCh := make(chan error, 1) go func() { errCh <- lc.Run() }() select { case err := <-errCh: if !errors.Is(err, hookErr) { t.Errorf("got %v, want %v", err, hookErr) } case <-time.After(2 * time.Second): t.Fatal("Run() did not return") } if c.startCalled { t.Error("OnStart should not be called when hook fails") } } func TestLauncher_Run_OnStartFails(t *testing.T) { lc := New(newLogger(), fastOpts()) c1 := &mockComponent{name: "c1"} startErr := errors.New("start failure") c2 := &mockComponent{name: "c2", startErr: startErr} lc.Append(c1, c2) errCh := make(chan error, 1) go func() { errCh <- lc.Run() }() select { case err := <-errCh: if !errors.Is(err, startErr) { t.Errorf("got %v, want %v", err, startErr) } case <-time.After(2 * time.Second): t.Fatal("Run() did not return") } if !c1.stopCalled { t.Error("c1 OnStop should be called when c2 OnStart fails") } } func TestLauncher_Shutdown_ReverseOrder(t *testing.T) { lc := New(newLogger(), fastOpts()) var stopOrder []string makeComponent := func(name string) *mockComponent { return &mockComponent{name: name} } c1 := makeComponent("c1") c2 := makeComponent("c2") c3 := makeComponent("c3") // Wrap OnStop to capture order type stoppable struct { *mockComponent recordStop func(string) } type recordingComponent struct { inner *mockComponent name string recordStop func(string) } rc := func(c *mockComponent, name string) Component { return &recordingWrapper{mockComponent: c, name: name, record: &stopOrder} } lc.Append(rc(c1, "c1"), rc(c2, "c2"), rc(c3, "c3")) err := runAndShutdown(t, lc, 10*time.Millisecond) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(stopOrder) != 3 { t.Fatalf("expected 3 stops, got %v", stopOrder) } if stopOrder[0] != "c3" || stopOrder[1] != "c2" || stopOrder[2] != "c1" { t.Errorf("stop order = %v, want [c3 c2 c1]", stopOrder) } _ = stoppable{} _ = recordingComponent{} } type recordingWrapper struct { *mockComponent name string record *[]string } func (r *recordingWrapper) OnStop() error { *r.record = append(*r.record, r.name) return r.mockComponent.OnStop() } func TestLauncher_Shutdown_ComponentTimeout(t *testing.T) { opts := Options{ComponentStopTimeout: 50 * time.Millisecond} lc := New(newLogger(), opts) // Component takes 200ms to stop — longer than the 50ms timeout. slow := &mockComponent{name: "slow", stopDelay: 200 * time.Millisecond} lc.Append(slow) start := time.Now() err := runAndShutdown(t, lc, 10*time.Millisecond) elapsed := time.Since(start) if err != nil { t.Fatalf("unexpected error: %v", err) } // Should not block for the full 200ms stop delay. if elapsed > 500*time.Millisecond { t.Errorf("Run took %v, expected less than 500ms (timeout should have fired)", elapsed) } } func TestLauncher_Shutdown_Idempotent(t *testing.T) { lc := New(newLogger(), fastOpts()) lc.Append(&mockComponent{name: "c"}) errCh := make(chan error, 1) go func() { errCh <- lc.Run() }() time.Sleep(10 * time.Millisecond) ctx := context.Background() // Call Shutdown twice — must not panic. lc.Shutdown(ctx) //nolint:errcheck lc.Shutdown(ctx) //nolint:errcheck select { case err := <-errCh: if err != nil { t.Fatalf("unexpected error: %v", err) } case <-time.After(2 * time.Second): t.Fatal("Run() did not return") } } func TestLauncher_Shutdown_ContextCancelled(t *testing.T) { lc := New(newLogger(), Options{ComponentStopTimeout: 500 * time.Millisecond}) slow := &mockComponent{name: "slow", stopDelay: 300 * time.Millisecond} lc.Append(slow) errCh := make(chan error, 1) go func() { errCh <- lc.Run() }() time.Sleep(10 * time.Millisecond) // Very short timeout — Shutdown should return ctx.Err() before Run completes. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() shutdownErr := lc.Shutdown(ctx) if !errors.Is(shutdownErr, context.DeadlineExceeded) { t.Errorf("Shutdown returned %v, want DeadlineExceeded", shutdownErr) } // Wait for Run to finish eventually. select { case <-errCh: case <-time.After(3 * time.Second): t.Fatal("Run() did not return") } } func TestLauncher_Run_MultipleComponents(t *testing.T) { lc := New(newLogger(), fastOpts()) components := make([]*mockComponent, 5) for i := range components { components[i] = &mockComponent{} lc.Append(components[i]) } err := runAndShutdown(t, lc, 10*time.Millisecond) if err != nil { t.Fatalf("unexpected error: %v", err) } for i, c := range components { if !c.initCalled || !c.startCalled || !c.stopCalled { t.Errorf("component[%d] lifecycle incomplete: init=%v start=%v stop=%v", i, c.initCalled, c.startCalled, c.stopCalled) } } } func TestLauncher_OnStop_ErrorLogged(t *testing.T) { lc := New(newLogger(), fastOpts()) stopErr := errors.New("stop error") c := &mockComponent{name: "c", stopErr: stopErr} lc.Append(c) // Run should still return nil even when OnStop returns an error. err := runAndShutdown(t, lc, 10*time.Millisecond) if err != nil { t.Fatalf("Run() returned error %v, want nil", err) } if !c.stopCalled { t.Error("OnStop was not called") } }