package app import ( "context" "errors" "sync/atomic" "testing" "time" "galaxy/lobby/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type fakeComponent struct { runErr error shutdownErr error runHook func(context.Context) error shutdownHook func(context.Context) error runCount atomic.Int32 downCount atomic.Int32 blockForCtx bool } func (component *fakeComponent) Run(ctx context.Context) error { component.runCount.Add(1) if component.runHook != nil { return component.runHook(ctx) } if component.blockForCtx { <-ctx.Done() return ctx.Err() } return component.runErr } func (component *fakeComponent) Shutdown(ctx context.Context) error { component.downCount.Add(1) if component.shutdownHook != nil { return component.shutdownHook(ctx) } return component.shutdownErr } func newCfg() config.Config { return config.Config{ShutdownTimeout: time.Second} } func TestAppValidateRejectsNonPositiveTimeout(t *testing.T) { t.Parallel() app := New(config.Config{}, &fakeComponent{blockForCtx: true}) err := app.Run(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "shutdown timeout must be positive") } func TestAppValidateRejectsNilComponent(t *testing.T) { t.Parallel() app := New(newCfg(), nil) err := app.Run(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "component 0 is nil") } func TestAppRunCancelledByContext(t *testing.T) { t.Parallel() component := &fakeComponent{blockForCtx: true} app := New(newCfg(), component) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { errCh <- app.Run(ctx) }() time.Sleep(10 * time.Millisecond) cancel() select { case err := <-errCh: require.NoError(t, err) case <-time.After(time.Second): t.Fatal("app did not stop after cancellation") } assert.Equal(t, int32(1), component.runCount.Load()) assert.Equal(t, int32(1), component.downCount.Load()) } func TestAppRunPropagatesComponentError(t *testing.T) { t.Parallel() failing := &fakeComponent{runErr: errors.New("boom")} blocking := &fakeComponent{blockForCtx: true} app := New(newCfg(), failing, blocking) err := app.Run(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "boom") assert.Equal(t, int32(1), blocking.downCount.Load()) } func TestAppRunEarlyCleanExit(t *testing.T) { t.Parallel() short := &fakeComponent{} // returns immediately without error blocking := &fakeComponent{blockForCtx: true} app := New(newCfg(), short, blocking) err := app.Run(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "exited without error before shutdown") } func TestAppRunShutdownCollectsErrors(t *testing.T) { t.Parallel() component := &fakeComponent{ blockForCtx: true, shutdownErr: errors.New("shutdown-boom"), } app := New(newCfg(), component) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { errCh <- app.Run(ctx) }() time.Sleep(10 * time.Millisecond) cancel() err := <-errCh require.Error(t, err) require.Contains(t, err.Error(), "shutdown-boom") } func TestAppRunNoComponentsBlocksUntilCancel(t *testing.T) { t.Parallel() app := New(newCfg()) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { errCh <- app.Run(ctx) }() time.Sleep(10 * time.Millisecond) cancel() select { case err := <-errCh: require.NoError(t, err) case <-time.After(time.Second): t.Fatal("app did not exit after cancel") } } func TestAppRunNilContext(t *testing.T) { t.Parallel() app := New(newCfg(), &fakeComponent{blockForCtx: true}) err := app.Run(nil) //nolint:staticcheck // test exercises the nil-context guard. require.Error(t, err) require.Contains(t, err.Error(), "nil context") }