package app import ( "context" "errors" "sync" "testing" "time" "galaxy/gateway/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAppRunWaitsForCancellationWithoutComponents(t *testing.T) { t.Parallel() application := New(config.Config{ShutdownTimeout: 50 * time.Millisecond}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() resultCh := make(chan error, 1) go func() { resultCh <- application.Run(ctx) }() select { case err := <-resultCh: require.FailNowf(t, "Run() returned early", "error=%v", err) case <-time.After(50 * time.Millisecond): } cancel() select { case err := <-resultCh: require.NoError(t, err) case <-time.After(time.Second): require.FailNow(t, "Run() did not return after cancellation") } } func TestAppRunCancelsComponentsAndCallsShutdownOnce(t *testing.T) { t.Parallel() first := newLifecycleComponent() second := newLifecycleComponent() application := New( config.Config{ShutdownTimeout: time.Second}, first, second, ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() resultCh := make(chan error, 1) go func() { resultCh <- application.Run(ctx) }() first.waitStarted(t) second.waitStarted(t) cancel() select { case err := <-resultCh: require.NoError(t, err) case <-time.After(time.Second): require.FailNow(t, "Run() did not return after cancellation") } first.waitRunExited(t) second.waitRunExited(t) assert.Equal(t, 1, first.shutdownCalls()) assert.Equal(t, 1, second.shutdownCalls()) } func TestAppRunReturnsComponentErrorAndStillShutsDown(t *testing.T) { t.Parallel() runErr := errors.New("boom") failing := newFailingComponent(runErr) blocking := newLifecycleComponent() application := New( config.Config{ShutdownTimeout: time.Second}, failing, blocking, ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() resultCh := make(chan error, 1) go func() { resultCh <- application.Run(ctx) }() failing.waitStarted(t) blocking.waitStarted(t) failing.releaseRun() select { case err := <-resultCh: require.Error(t, err) assert.ErrorIs(t, err, runErr) case <-time.After(time.Second): require.FailNow(t, "Run() did not return after component failure") } failing.waitRunExited(t) blocking.waitRunExited(t) assert.Equal(t, 1, failing.shutdownCalls()) assert.Equal(t, 1, blocking.shutdownCalls()) } // lifecycleComponent blocks in Run until the application calls Shutdown. type lifecycleComponent struct { startedCh chan struct{} runDoneCh chan struct{} stopCh chan struct{} shutdownMu sync.Mutex shutdownCnt int } // newLifecycleComponent builds a component that exits Run only after Shutdown // signals its stop channel. func newLifecycleComponent() *lifecycleComponent { return &lifecycleComponent{ startedCh: make(chan struct{}), runDoneCh: make(chan struct{}), stopCh: make(chan struct{}), } } // Run marks the component as started, waits for cancellation, and then blocks // until Shutdown releases the stop channel. func (c *lifecycleComponent) Run(ctx context.Context) error { close(c.startedCh) defer close(c.runDoneCh) <-ctx.Done() <-c.stopCh return nil } // Shutdown records the call and releases the run loop. func (c *lifecycleComponent) Shutdown(context.Context) error { c.shutdownMu.Lock() defer c.shutdownMu.Unlock() c.shutdownCnt++ if c.shutdownCnt == 1 { close(c.stopCh) } return nil } // waitStarted blocks until Run has started or fails the test on timeout. func (c *lifecycleComponent) waitStarted(t *testing.T) { t.Helper() select { case <-c.startedCh: case <-time.After(time.Second): require.FailNow(t, "component did not start") } } // waitRunExited blocks until Run exits or fails the test on timeout. func (c *lifecycleComponent) waitRunExited(t *testing.T) { t.Helper() select { case <-c.runDoneCh: case <-time.After(time.Second): require.FailNow(t, "component run did not exit") } } // shutdownCalls returns the number of observed Shutdown invocations. func (c *lifecycleComponent) shutdownCalls() int { c.shutdownMu.Lock() defer c.shutdownMu.Unlock() return c.shutdownCnt } // failingComponent returns a predefined error once released by the test and // still tracks shutdown calls. type failingComponent struct { startedCh chan struct{} releaseCh chan struct{} runDoneCh chan struct{} shutdownMu sync.Mutex shutdownCnt int err error } // newFailingComponent builds a component whose Run returns err after release. func newFailingComponent(err error) *failingComponent { return &failingComponent{ startedCh: make(chan struct{}), releaseCh: make(chan struct{}), runDoneCh: make(chan struct{}), err: err, } } // Run waits until the test releases it and then returns the configured error. func (c *failingComponent) Run(context.Context) error { close(c.startedCh) defer close(c.runDoneCh) <-c.releaseCh return c.err } // Shutdown records that the application attempted graceful shutdown. func (c *failingComponent) Shutdown(context.Context) error { c.shutdownMu.Lock() defer c.shutdownMu.Unlock() c.shutdownCnt++ return nil } // waitStarted blocks until Run has started or fails the test on timeout. func (c *failingComponent) waitStarted(t *testing.T) { t.Helper() select { case <-c.startedCh: case <-time.After(time.Second): require.FailNow(t, "failing component did not start") } } // releaseRun allows Run to return its configured error. func (c *failingComponent) releaseRun() { close(c.releaseCh) } // waitRunExited blocks until Run exits or fails the test on timeout. func (c *failingComponent) waitRunExited(t *testing.T) { t.Helper() select { case <-c.runDoneCh: case <-time.After(time.Second): require.FailNow(t, "failing component run did not exit") } } // shutdownCalls returns the number of observed Shutdown invocations. func (c *failingComponent) shutdownCalls() int { c.shutdownMu.Lock() defer c.shutdownMu.Unlock() return c.shutdownCnt }