Files
galaxy-game/lobby/internal/app/app_test.go
T
2026-04-25 23:20:55 +02:00

174 lines
3.8 KiB
Go

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")
}