Files
galaxy-game/gateway/internal/app/app_test.go
T
2026-04-02 19:18:42 +02:00

269 lines
5.9 KiB
Go

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
}